Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,66 @@ public struct ClaudeHookConfigWriter: HookConfigWriter {
try data.write(to: URL(fileURLWithPath: settingsPath))
}

// MARK: - Gateway env

/// Keys we manage inside the settings `env` block (CROW-402).
private static let gatewayEnvKeys = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS"]

/// Write (or clear) the AI-gateway env vars in a directory's
/// `.claude/settings.local.json` `env` block, merging with existing settings.
///
/// Claude Code reads this `env` block on every launch, so this makes the
/// gateway survive manual `claude` re-runs in the terminal — not just the
/// initial launch (CROW-402). Pass a resolved gateway to set the vars, or
/// `nil` to remove them (so switching a workspace off its gateway clears the
/// stale values rather than leaving them behind).
///
/// `dirPath` is the worktree path for work/job/review sessions, or the dev
/// root for the Manager session.
public static func writeGatewayEnv(dirPath: String, resolved: GatewayResolver.Resolved?) {
let claudeDir = (dirPath as NSString).appendingPathComponent(".claude")
let settingsPath = (claudeDir as NSString).appendingPathComponent("settings.local.json")

// Read existing settings if present.
var settings: [String: Any] = [:]
if let data = FileManager.default.contents(atPath: settingsPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}

var env = settings["env"] as? [String: Any] ?? [:]
if let resolved {
env["ANTHROPIC_BASE_URL"] = resolved.baseURL
env["ANTHROPIC_CUSTOM_HEADERS"] = resolved.customHeaders
} else {
for key in gatewayEnvKeys { env.removeValue(forKey: key) }
}

if env.isEmpty {
settings.removeValue(forKey: "env")
} else {
settings["env"] = env
}

// Nothing to write and no file to clean up.
if settings.isEmpty && !FileManager.default.fileExists(atPath: settingsPath) {
return
}

do {
try FileManager.default.createDirectory(atPath: claudeDir, withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try data.write(to: URL(fileURLWithPath: settingsPath))
// The env block can carry a resolved bearer token, so restrict the
// file to owner-only — matching ConfigStore's 0600 on config.json.
try? FileManager.default.setAttributes(
[.posixPermissions: 0o600], ofItemAtPath: settingsPath)
} catch {
NSLog("[ClaudeHookConfigWriter] Failed to write gateway env to %@: %@",
settingsPath, error.localizedDescription)
}
}

/// Remove our hook entries from a worktree's settings.local.json, preserving user settings.
public func removeHookConfig(worktreePath: String) {
let settingsPath = (worktreePath as NSString)
Expand Down
33 changes: 33 additions & 0 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CrowCore

/// Helpers for building the argument string appended to a `claude` shell invocation.
///
Expand Down Expand Up @@ -40,4 +41,36 @@ public enum ClaudeLaunchArgs {
}
return s
}

/// Shell prefix that applies (or clears) the AI-gateway env vars on the
/// `claude` launch line (CROW-402). Placed immediately before the `claude`
/// binary path so it overrides any value exported by the user's `~/.zshrc`
/// for this invocation. Re-runs are covered separately by the
/// `settings.local.json` `env` block, so this is the initial-launch override
/// and the load-bearing no-leak guard.
///
/// Uses `export … &&` (not bare `VAR=val` command-prefix assignments) so it
/// composes correctly in front of the OTEL `export … &&` prefix that
/// `ClaudeCodeAgent.autoLaunchCommand` bakes into the launch string — a bare
/// `VAR=val` prefix would bind only to that following `export` builtin, not to
/// the eventual `claude` process.
///
/// - `resolved` present → `export ANTHROPIC_BASE_URL='…' ANTHROPIC_CUSTOM_HEADERS='…' && `
/// - `resolved` nil → `unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && `
/// so a no-gateway workspace doesn't inherit a sibling's or `~/.zshrc`'s gateway.
/// - multi-header → the header value has an embedded newline and can't go on
/// the line (a pasted newline would submit the command early), so it's
/// carried solely by `settings.local.json`. We still `unset ANTHROPIC_CUSTOM_HEADERS`
/// before exporting `ANTHROPIC_BASE_URL`, so the gateway's baseURL is never
/// paired with a stale `~/.zshrc`-inherited header value.
public static func gatewayEnvPrefix(_ resolved: GatewayResolver.Resolved?) -> String {
guard let resolved else {
return "unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && "
}
let baseAssignment = "export ANTHROPIC_BASE_URL=\(shellQuote(resolved.baseURL))"
if resolved.customHeaders.contains("\n") {
return "unset ANTHROPIC_CUSTOM_HEADERS && " + baseAssignment + " && "
}
return baseAssignment + " ANTHROPIC_CUSTOM_HEADERS=\(shellQuote(resolved.customHeaders)) && "
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Testing
import CrowCore
@testable import CrowClaude

@Test func claudeLaunchArgsDisabledReturnsEmpty() {
Expand Down Expand Up @@ -51,3 +52,40 @@ import Testing
== " --rc --name 'Manager'")
#expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: nil) == "")
}

// MARK: - Launch-line gateway prefix (ClaudeLaunchArgs.gatewayEnvPrefix, CROW-402)

@Test func gatewayEnvPrefixUnsetsWhenNil() throws {
// No gateway → explicitly unset so a global ~/.zshrc export (or a sibling
// workspace's gateway) can't bleed into this launch.
#expect(ClaudeLaunchArgs.gatewayEnvPrefix(nil) == "unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ")
}

@Test func gatewayEnvPrefixExportsSingleHeader() throws {
// Single header → both vars go on the launch line via `export … &&` so they
// compose in front of any OTEL `export … &&` prefix and reach `claude`.
let resolved = GatewayResolver.Resolved(
baseURL: "https://corveil.io",
customHeaders: "x-citadel-api-key: Bearer sk-1"
)
let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved)
#expect(prefix == "export ANTHROPIC_BASE_URL='https://corveil.io' ANTHROPIC_CUSTOM_HEADERS='x-citadel-api-key: Bearer sk-1' && ")
#expect(!prefix.contains("\n"))
}

@Test func gatewayEnvPrefixUnsetsInheritedHeadersForMultiLine() throws {
// A multi-header value has an embedded newline; pasting it onto the launch
// line would submit the command early, so it's carried by settings.local.json.
// The prefix must still `unset ANTHROPIC_CUSTOM_HEADERS` so the gateway's
// baseURL isn't paired with a stale ~/.zshrc-inherited header value, and must
// not contain a raw newline.
let resolved = GatewayResolver.Resolved(
baseURL: "https://corveil.io",
customHeaders: "x-a: one\nx-b: two"
)
let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved)
#expect(prefix == "unset ANTHROPIC_CUSTOM_HEADERS && export ANTHROPIC_BASE_URL='https://corveil.io' && ")
#expect(prefix.contains("unset ANTHROPIC_CUSTOM_HEADERS"))
#expect(prefix.contains("export ANTHROPIC_BASE_URL='https://corveil.io'"))
#expect(!prefix.contains("\n"))
}
116 changes: 116 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation

/// Resolves a `WorkspaceGateway` into launch-ready environment-variable values
/// for a `claude` invocation (CROW-402).
///
/// A header value may be a plaintext string or an `op://…` 1Password reference.
/// References are resolved at launch via the `op` CLI so the secret never lands
/// at rest in `config.json`; any other value is used literally. The serialized
/// output matches Claude Code's contract: `ANTHROPIC_BASE_URL` is the gateway
/// endpoint and `ANTHROPIC_CUSTOM_HEADERS` is newline-separated `Name: Value`
/// header lines.
///
/// Resolved secret values are never logged.
public enum GatewayResolver {
/// Launch-ready env values derived from a gateway.
public struct Resolved: Equatable, Sendable {
/// Value for `ANTHROPIC_BASE_URL`.
public var baseURL: String
/// Value for `ANTHROPIC_CUSTOM_HEADERS` — newline-separated `Name: Value`.
public var customHeaders: String

public init(baseURL: String, customHeaders: String) {
self.baseURL = baseURL
self.customHeaders = customHeaders
}
}

/// Serialize a resolved header map into the `ANTHROPIC_CUSTOM_HEADERS` value:
/// newline-separated `Name: Value`, sorted by name for deterministic output.
public static func serializeHeaders(_ headers: [String: String]) -> String {
headers
.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)" }
.joined(separator: "\n")
}

/// Resolve a gateway's header values (resolving `op://…` references) and
/// serialize them for launch. Returns `nil` for an empty gateway (caller
/// should then *unset* the env vars rather than set them).
///
/// - Parameter resolveSecret: Injected for testability; defaults to `op read`.
/// When a reference fails to resolve, the header is dropped and a redacted
/// warning is logged — the `baseURL` is still applied, so requests reach the
/// gateway and fail loudly there (a 401) rather than silently falling back
/// to the vanilla Anthropic API with the user's default key.
public static func resolve(
_ gateway: WorkspaceGateway,
resolveSecret: (String) -> String? = Self.opRead
) -> Resolved? {
guard !gateway.isEmpty else { return nil }

var resolvedHeaders: [String: String] = [:]
for (name, value) in gateway.customHeaders {
if value.hasPrefix("op://") {
if let secret = resolveSecret(value) {
resolvedHeaders[name] = secret
} else {
NSLog("[GatewayResolver] Failed to resolve secret reference for header '%@' (op read failed or op not signed in); dropping this header — the gateway will reject the request", name)
}
} else {
resolvedHeaders[name] = value
}
}

return Resolved(
baseURL: gateway.baseURL,
customHeaders: serializeHeaders(resolvedHeaders)
)
}

/// Resolve a single `op://…` reference via the 1Password CLI (`op read`).
/// Returns `nil` if `op` is missing, not signed in, or the read fails.
/// The resolved value is returned to the caller but never logged.
public static func opRead(_ reference: String) -> String? {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["op", "read", reference]
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
// Resolved PATH so a Homebrew-installed `op` is found; inherits HOME so
// `op`'s session/biometric config is available.
process.environment = ShellEnvironment.shared.env

do {
try process.run()
} catch {
NSLog("[GatewayResolver] Failed to launch `op` for secret resolution: %@", error.localizedDescription)
return nil
}

// Bound the wait so a stuck `op` (e.g. waiting on biometric prompt) can't
// hang a session launch indefinitely.
let deadline = DispatchTime.now() + .seconds(15)
let done = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
process.waitUntilExit()
done.signal()
}
if done.wait(timeout: deadline) == .timedOut {
process.terminate()
NSLog("[GatewayResolver] `op read` timed out resolving a secret reference")
return nil
}

guard process.terminationStatus == 0 else {
NSLog("[GatewayResolver] `op read` exited with status %d", process.terminationStatus)
return nil
}

let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let output = String(data: data, encoding: .utf8) else { return nil }
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Loading
Loading