diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift index b171f932..e6c8e084 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift @@ -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) diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift index 8c9ec852..b6fb6004 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift @@ -1,4 +1,5 @@ import Foundation +import CrowCore /// Helpers for building the argument string appended to a `claude` shell invocation. /// @@ -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)) && " + } } diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift index 94b1c8a0..0a327638 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import CrowCore @testable import CrowClaude @Test func claudeLaunchArgsDisabledReturnsEmpty() { @@ -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")) +} diff --git a/Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift b/Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift new file mode 100644 index 00000000..645d2d40 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift @@ -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 + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 89d02d61..b9a3ad59 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -58,6 +58,13 @@ public struct AppConfig: Codable, Sendable, Equatable { /// `{"review": "codex"}` — Swift's default `JSONEncoder` only treats /// dictionaries with `String`/`Int` keys as JSON objects. public var agentsByKind: [String: AgentKind] + /// Optional AI gateway for the Manager session's `claude` launch. The + /// Manager sits at `devRoot` and isn't bound to a single workspace, so it + /// has its own gateway rather than inheriting any one workspace's. When nil, + /// the Manager uses the vanilla Anthropic API (env vars explicitly unset so a + /// global `~/.zshrc` export doesn't bleed in). Per-workspace `gateway` blocks + /// apply to non-Manager sessions only (CROW-402). + public var managerGateway: WorkspaceGateway? public init( workspaces: [WorkspaceInfo] = [], @@ -76,7 +83,8 @@ public struct AppConfig: Codable, Sendable, Equatable { cleanup: CleanupConfig = CleanupConfig(), jobs: [JobConfig] = [], defaultAgentKind: AgentKind = .claudeCode, - agentsByKind: [String: AgentKind] = [:] + agentsByKind: [String: AgentKind] = [:], + managerGateway: WorkspaceGateway? = nil ) { self.workspaces = workspaces self.defaults = defaults @@ -95,6 +103,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.jobs = jobs self.defaultAgentKind = defaultAgentKind self.agentsByKind = agentsByKind + self.managerGateway = managerGateway } public init(from decoder: Decoder) throws { @@ -116,10 +125,11 @@ public struct AppConfig: Codable, Sendable, Equatable { jobs = try container.decodeIfPresent([JobConfig].self, forKey: .jobs) ?? [] defaultAgentKind = try container.decodeIfPresent(AgentKind.self, forKey: .defaultAgentKind) ?? .claudeCode agentsByKind = try container.decodeIfPresent([String: AgentKind].self, forKey: .agentsByKind) ?? [:] + managerGateway = try container.decodeIfPresent(WorkspaceGateway.self, forKey: .managerGateway) } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind, managerGateway } /// Resolve the agent that should drive a newly-created session of the @@ -132,6 +142,88 @@ public struct AppConfig: Codable, Sendable, Equatable { } } +/// Per-workspace (or per-Manager) AI gateway configuration. When present, the +/// `claude` launches it applies to inherit `ANTHROPIC_BASE_URL` (from `baseURL`) +/// and `ANTHROPIC_CUSTOM_HEADERS` (from `customHeaders`, serialized to +/// newline-separated `Name: Value` lines). When absent, those env vars are +/// explicitly unset before launch so a global `~/.zshrc` export — or a sibling +/// workspace's gateway — doesn't bleed in (CROW-402). +/// +/// A header value may be a plaintext string or a secret reference. `op://…` +/// references are resolved at launch via the 1Password CLI (`op read`) so the +/// secret never lands at rest in `config.json`; any other value is treated +/// literally (plaintext — stored in `config.json`, so warn in the UI). +public struct WorkspaceGateway: Codable, Sendable, Equatable { + public var baseURL: String + public var customHeaders: [String: String] + + public init(baseURL: String, customHeaders: [String: String]) { + self.baseURL = baseURL + self.customHeaders = customHeaders + } + + /// Whether this gateway has anything to apply. A gateway whose `baseURL` is + /// blank and whose `customHeaders` is empty is treated as "no gateway". + public var isEmpty: Bool { + baseURL.trimmingCharacters(in: .whitespaces).isEmpty && customHeaders.isEmpty + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedBaseURL = try container.decodeIfPresent(String.self, forKey: .baseURL) ?? "" + let decodedHeaders = try container.decodeIfPresent([String: String].self, forKey: .customHeaders) ?? [:] + + // Reject a half-filled block at parse time (CROW-402): a baseURL with no + // headers can't authenticate against the gateway, and headers with no + // baseURL have nothing to attach to. Both-empty is allowed (it just means + // "no gateway"); both-present is the valid case. + let hasBaseURL = !decodedBaseURL.trimmingCharacters(in: .whitespaces).isEmpty + let hasHeaders = !decodedHeaders.isEmpty + if hasBaseURL != hasHeaders { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "gateway must set both baseURL and customHeaders, or neither (got baseURL: \(hasBaseURL ? "present" : "empty"), customHeaders: \(hasHeaders ? "present" : "empty"))" + ) + ) + } + + baseURL = decodedBaseURL + customHeaders = decodedHeaders + } + + private enum CodingKeys: String, CodingKey { + case baseURL, customHeaders + } +} + +extension WorkspaceGateway { + /// Parse a multiline `Name: Value` editor string into a header map. Blank + /// lines are ignored; each line's first `:` splits name from value. Used by + /// the Settings UI so a free-text editor maps to the `customHeaders` dict. + public static func parseHeaderLines(_ text: String) -> [String: String] { + var result: [String: String] = [:] + for raw in text.split(separator: "\n", omittingEmptySubsequences: true) { + let line = raw.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty, let colon = line.firstIndex(of: ":") else { continue } + let name = String(line[.. String { + headers + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: "\n") + } +} + /// Opt-in settings that let Crow type instructions into a session's managed /// Claude Code terminal when a watched PR transitions into a state that /// usually requires action. Both flags default off — typing into a terminal @@ -179,6 +271,12 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { public var alwaysInclude: [String] // repos to always list in prompt table public var autoReviewRepos: [String] // repos where review requests auto-create a review session public var customInstructions: String? // free-text instructions appended to session prompts + /// Optional AI gateway. When set, `claude` launches into this workspace + /// inherit `ANTHROPIC_BASE_URL`/`ANTHROPIC_CUSTOM_HEADERS` derived from it; + /// when nil, those env vars are explicitly unset so a global `~/.zshrc` + /// export doesn't leak in (CROW-402). Does not apply to the Manager session, + /// which has its own `AppConfig.managerGateway`. + public var gateway: WorkspaceGateway? /// The CLI tool name derived from the current `provider` value. /// Unlike `cli` (which may be stale from an old config file), this is always correct. @@ -194,7 +292,8 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { host: String? = nil, alwaysInclude: [String] = [], autoReviewRepos: [String] = [], - customInstructions: String? = nil + customInstructions: String? = nil, + gateway: WorkspaceGateway? = nil ) { self.id = id self.name = name @@ -204,6 +303,7 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { self.alwaysInclude = alwaysInclude self.autoReviewRepos = autoReviewRepos self.customInstructions = customInstructions + self.gateway = gateway } public init(from decoder: Decoder) throws { @@ -216,10 +316,11 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { alwaysInclude = try container.decodeIfPresent([String].self, forKey: .alwaysInclude) ?? [] autoReviewRepos = try container.decodeIfPresent([String].self, forKey: .autoReviewRepos) ?? [] customInstructions = try container.decodeIfPresent(String.self, forKey: .customInstructions) + gateway = try container.decodeIfPresent(WorkspaceGateway.self, forKey: .gateway) } private enum CodingKeys: String, CodingKey { - case id, name, provider, cli, host, alwaysInclude, autoReviewRepos, customInstructions + case id, name, provider, cli, host, alwaysInclude, autoReviewRepos, customInstructions, gateway } /// Characters that are unsafe in directory names (workspace names become directory names). diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 20dea78c..b9bbbf8a 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -408,3 +408,105 @@ import Testing let config = try JSONDecoder().decode(AppConfig.self, from: json) #expect(config.defaults.ignoreReviewLabels.isEmpty) } + +// MARK: - AI gateway (CROW-402) + +@Test func workspaceGatewayRoundTrip() throws { + let config = AppConfig(workspaces: [ + WorkspaceInfo( + name: "RadiusMethod", + gateway: WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key"] + ) + ) + ]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.workspaces[0].gateway?.baseURL == "https://corveil.io") + #expect(decoded.workspaces[0].gateway?.customHeaders["x-citadel-api-key"] == "op://Spotlight Prod/Citadel/api_key") +} + +@Test func workspaceGatewayDefaultsNilWhenKeyMissing() throws { + // Legacy configs without the key decode with a nil gateway. + let json = """ + {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh"}]} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.workspaces[0].gateway == nil) +} + +@Test func managerGatewayRoundTrip() throws { + var config = AppConfig() + config.managerGateway = WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "Bearer sk-citadel-123"] + ) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.managerGateway?.baseURL == "https://corveil.io") + #expect(decoded.managerGateway?.customHeaders["x-citadel-api-key"] == "Bearer sk-citadel-123") +} + +@Test func managerGatewayDefaultsNilWhenKeyMissing() throws { + let json = "{}".data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.managerGateway == nil) +} + +@Test func gatewayBothEmptyDecodesAsNoGateway() throws { + // Both fields blank/empty is allowed — it just means "no gateway". + let json = #"{"baseURL": "", "customHeaders": {}}"#.data(using: .utf8)! + let gateway = try JSONDecoder().decode(WorkspaceGateway.self, from: json) + #expect(gateway.isEmpty) +} + +@Test func gatewayBaseURLWithoutHeadersThrows() throws { + // A baseURL with no headers can't authenticate — reject at parse time. + let json = #"{"baseURL": "https://corveil.io", "customHeaders": {}}"#.data(using: .utf8)! + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(WorkspaceGateway.self, from: json) + } +} + +@Test func gatewayHeadersWithoutBaseURLThrows() throws { + // Headers with no baseURL have nothing to attach to — reject at parse time. + let json = #"{"baseURL": "", "customHeaders": {"x-key": "secret"}}"#.data(using: .utf8)! + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(WorkspaceGateway.self, from: json) + } +} + +@Test func malformedWorkspaceGatewayFailsConfigDecode() throws { + // A malformed gateway inside a workspace propagates as a decode failure + // (ConfigStore.loadConfig logs it and returns nil rather than silently + // dropping just the bad field). + let json = """ + {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh", "gateway": {"baseURL": "https://corveil.io", "customHeaders": {}}}]} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(AppConfig.self, from: json) + } +} + +@Test func gatewayHeaderLinesRoundTrip() throws { + let headers = ["x-b": "two", "x-a": "Bearer one"] + let text = WorkspaceGateway.headerLines(from: headers) + #expect(text == "x-a: Bearer one\nx-b: two") // sorted + #expect(WorkspaceGateway.parseHeaderLines(text) == headers) +} + +@Test func gatewayParseHeaderLinesIgnoresBlankAndMalformedLines() throws { + let text = """ + x-key: Bearer sk-1 + + x-op : op://Vault/Item/field + not-a-header-line + : missing-name + """ + let parsed = WorkspaceGateway.parseHeaderLines(text) + #expect(parsed["x-key"] == "Bearer sk-1") + #expect(parsed["x-op"] == "op://Vault/Item/field") + #expect(parsed["not-a-header-line"] == nil) // no colon → ignored + #expect(parsed.count == 2) // ": missing-name" has empty name → ignored +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift new file mode 100644 index 00000000..26080270 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func gatewayResolverSerializesHeadersSortedNewlineSeparated() throws { + let lines = GatewayResolver.serializeHeaders([ + "x-b": "two", + "x-a": "one", + ]) + // Sorted by name for determinism, newline-separated "Name: Value". + #expect(lines == "x-a: one\nx-b: two") +} + +@Test func gatewayResolverReturnsNilForEmptyGateway() throws { + let empty = WorkspaceGateway(baseURL: "", customHeaders: [:]) + #expect(GatewayResolver.resolve(empty) { _ in "unused" } == nil) +} + +@Test func gatewayResolverPassesPlaintextThrough() throws { + let gateway = WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "Bearer sk-plain"] + ) + // resolveSecret must NOT be consulted for a plaintext value. + let resolved = GatewayResolver.resolve(gateway) { _ in + Issue.record("op read should not be called for a plaintext header") + return nil + } + #expect(resolved?.baseURL == "https://corveil.io") + #expect(resolved?.customHeaders == "x-citadel-api-key: Bearer sk-plain") +} + +@Test func gatewayResolverResolvesOpReference() throws { + let gateway = WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key"] + ) + var requestedRef: String? + let resolved = GatewayResolver.resolve(gateway) { ref in + requestedRef = ref + return "Bearer sk-resolved" + } + #expect(requestedRef == "op://Spotlight Prod/Citadel/api_key") + #expect(resolved?.customHeaders == "x-citadel-api-key: Bearer sk-resolved") +} + +@Test func gatewayResolverDropsHeaderWhenSecretResolutionFails() throws { + let gateway = WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: [ + "x-citadel-api-key": "op://Vault/Item/missing", + "x-plain": "kept", + ] + ) + // Secret fails to resolve → that header is dropped, baseURL + plaintext kept + // (gateway rejects the request loudly rather than falling back to vanilla). + let resolved = GatewayResolver.resolve(gateway) { _ in nil } + #expect(resolved?.baseURL == "https://corveil.io") + #expect(resolved?.customHeaders == "x-plain: kept") +} diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift index 6611711b..c2b07206 100644 --- a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift @@ -117,3 +117,50 @@ import Testing let dirPerms = dirAttrs[.posixPermissions] as? Int #expect(dirPerms == 0o700) } + +@Test func configStoreGatewayRoundTrip() throws { + // A workspace gateway + managerGateway survive a save/load through disk (CROW-402). + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let claudeDir = tmpDir.appendingPathComponent(".claude", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + var config = AppConfig( + workspaces: [ + WorkspaceInfo( + name: "RadiusMethod", + gateway: WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key"] + ) + ), + WorkspaceInfo(name: "Personal"), // no gateway → vanilla Anthropic + ] + ) + config.managerGateway = WorkspaceGateway( + baseURL: "https://corveil.io", + customHeaders: ["x-citadel-api-key": "Bearer sk-citadel-456"] + ) + + try ConfigStore.saveConfig(config, to: claudeDir) + let configURL = claudeDir.appendingPathComponent("config.json") + let loaded = ConfigStore.loadConfig(from: configURL) + + #expect(loaded?.workspaces[0].gateway?.baseURL == "https://corveil.io") + #expect(loaded?.workspaces[0].gateway?.customHeaders["x-citadel-api-key"] == "op://Spotlight Prod/Citadel/api_key") + #expect(loaded?.workspaces[1].gateway == nil) + #expect(loaded?.managerGateway?.customHeaders["x-citadel-api-key"] == "Bearer sk-citadel-456") +} + +@Test func configStoreLoadReturnsNilOnMalformedGateway() throws { + // A half-filled gateway (baseURL but no headers) is rejected at decode time; + // loadConfig logs and returns nil rather than dropping just the bad field. + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmpDir) } + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + let configURL = tmpDir.appendingPathComponent("config.json") + try #"{"managerGateway": {"baseURL": "https://corveil.io", "customHeaders": {}}}"# + .write(to: configURL, atomically: true, encoding: .utf8) + + #expect(ConfigStore.loadConfig(from: configURL) == nil) +} diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index eda77775..1babb012 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -8,6 +8,7 @@ public struct AutomationSettingsView: View { @Binding var defaults: ConfigDefaults @Binding var remoteControlEnabled: Bool @Binding var managerAutoPermissionMode: Bool + @Binding var managerGateway: WorkspaceGateway? @Binding var autoRespond: AutoRespondSettings @Binding var attributionTrailers: Bool @Binding var autoMergeWatcherEnabled: Bool @@ -18,11 +19,14 @@ public struct AutomationSettingsView: View { @State private var excludeReviewReposText: String @State private var ignoreReviewLabelsText: String @State private var excludeTicketReposText: String + @State private var managerGatewayBaseURL: String + @State private var managerGatewayHeadersText: String public init( defaults: Binding, remoteControlEnabled: Binding, managerAutoPermissionMode: Binding, + managerGateway: Binding, autoRespond: Binding, attributionTrailers: Binding, autoMergeWatcherEnabled: Binding, @@ -33,6 +37,7 @@ public struct AutomationSettingsView: View { self._defaults = defaults self._remoteControlEnabled = remoteControlEnabled self._managerAutoPermissionMode = managerAutoPermissionMode + self._managerGateway = managerGateway self._autoRespond = autoRespond self._attributionTrailers = attributionTrailers self._autoMergeWatcherEnabled = autoMergeWatcherEnabled @@ -42,6 +47,35 @@ public struct AutomationSettingsView: View { self._excludeReviewReposText = State(initialValue: defaults.wrappedValue.excludeReviewRepos.joined(separator: ", ")) self._ignoreReviewLabelsText = State(initialValue: defaults.wrappedValue.ignoreReviewLabels.joined(separator: ", ")) self._excludeTicketReposText = State(initialValue: defaults.wrappedValue.excludeTicketRepos.joined(separator: ", ")) + self._managerGatewayBaseURL = State(initialValue: managerGateway.wrappedValue?.baseURL ?? "") + self._managerGatewayHeadersText = State(initialValue: managerGateway.wrappedValue.map { + WorkspaceGateway.headerLines(from: $0.customHeaders) + } ?? "") + } + + /// Reject a half-filled Manager gateway (base URL xor headers), matching the + /// parse-time validation in `WorkspaceGateway`. + private var managerGatewayValidationError: String? { + let hasBaseURL = !managerGatewayBaseURL.trimmingCharacters(in: .whitespaces).isEmpty + let hasHeaders = !WorkspaceGateway.parseHeaderLines(managerGatewayHeadersText).isEmpty + if hasBaseURL && !hasHeaders { return "Add at least one custom header, or clear the Base URL." } + if hasHeaders && !hasBaseURL { return "Set a Base URL, or remove the custom headers." } + return nil + } + + /// Push the editor fields back into the `managerGateway` binding (nil when + /// both are empty, or while half-filled so an invalid block isn't persisted). + private func commitManagerGateway() { + let trimmedURL = managerGatewayBaseURL.trimmingCharacters(in: .whitespaces) + let headers = WorkspaceGateway.parseHeaderLines(managerGatewayHeadersText) + if trimmedURL.isEmpty && headers.isEmpty { + managerGateway = nil + } else if managerGatewayValidationError == nil { + managerGateway = WorkspaceGateway(baseURL: trimmedURL, customHeaders: headers) + } else { + return // half-filled — don't persist until valid + } + onSave?() } public var body: some View { @@ -108,6 +142,34 @@ public struct AutomationSettingsView: View { .foregroundStyle(.secondary) } + Section("Manager AI Gateway") { + TextField("Base URL (e.g., https://corveil.io)", text: $managerGatewayBaseURL) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onChange(of: managerGatewayBaseURL) { _, _ in commitManagerGateway() } + + Text("Custom Headers") + .font(.caption) + .foregroundStyle(.secondary) + TextEditor(text: $managerGatewayHeadersText) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 60) + .onChange(of: managerGatewayHeadersText) { _, _ in commitManagerGateway() } + Text("One `Name: Value` per line. A value starting with `op://` is resolved at launch via the 1Password CLI and kept out of config.json (the resolved value is cached owner-only in settings.local.json); any other value is stored in plain text in config.json — prefer an `op://` reference for production keys.") + .font(.caption) + .foregroundStyle(.secondary) + + if let error = managerGatewayValidationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Text("The Manager sits at the dev root and isn't tied to one workspace, so it has its own gateway. Per-workspace gateways are set under Workspaces. Leave empty to use the vanilla Anthropic API. Takes effect on next app launch.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Attribution") { Toggle("Add Crow-Session trailer to commits", isOn: $attributionTrailers) .onChange(of: attributionTrailers) { _, _ in onSave?() } diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index e8d56a61..b1605343 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -47,6 +47,7 @@ public struct SettingsView: View { defaults: $config.defaults, remoteControlEnabled: $config.remoteControlEnabled, managerAutoPermissionMode: $config.managerAutoPermissionMode, + managerGateway: $config.managerGateway, autoRespond: $config.autoRespond, attributionTrailers: $config.attributionTrailers, autoMergeWatcherEnabled: $config.autoMergeWatcherEnabled, diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 34b1dd45..18808994 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -14,6 +14,8 @@ public struct WorkspaceFormView: View { @State private var alwaysIncludeText: String @State private var autoReviewReposText: String @State private var customInstructionsText: String + @State private var gatewayBaseURL: String + @State private var gatewayHeadersText: String private let existingID: UUID? private let existingNames: [String] @@ -35,6 +37,10 @@ public struct WorkspaceFormView: View { self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") self._autoReviewReposText = State(initialValue: workspace?.autoReviewRepos.joined(separator: ", ") ?? "") self._customInstructionsText = State(initialValue: workspace?.customInstructions ?? "") + self._gatewayBaseURL = State(initialValue: workspace?.gateway?.baseURL ?? "") + self._gatewayHeadersText = State(initialValue: workspace?.gateway.map { + WorkspaceGateway.headerLines(from: $0.customHeaders) + } ?? "") self.existingNames = existingNames self.onSave = onSave } @@ -47,6 +53,34 @@ public struct WorkspaceFormView: View { WorkspaceInfo.validateName(trimmedName, existingNames: existingNames) } + /// Parsed header map from the editor text. + private var parsedHeaders: [String: String] { + WorkspaceGateway.parseHeaderLines(gatewayHeadersText) + } + + /// Reject a half-filled gateway (base URL xor headers) — matches the + /// parse-time validation in `WorkspaceGateway` so the UI never writes a + /// config the decoder would later reject. + private var gatewayValidationError: String? { + let hasBaseURL = !gatewayBaseURL.trimmingCharacters(in: .whitespaces).isEmpty + let hasHeaders = !parsedHeaders.isEmpty + if hasBaseURL && !hasHeaders { + return "Add at least one custom header (e.g. an API key), or clear the Base URL." + } + if hasHeaders && !hasBaseURL { + return "Set a Base URL, or remove the custom headers." + } + return nil + } + + /// The gateway to persist, or nil when both fields are empty. + private var gatewayForSave: WorkspaceGateway? { + let trimmedURL = gatewayBaseURL.trimmingCharacters(in: .whitespaces) + let headers = parsedHeaders + if trimmedURL.isEmpty && headers.isEmpty { return nil } + return WorkspaceGateway(baseURL: trimmedURL, customHeaders: headers) + } + public var body: some View { Form { Section("Workspace") { @@ -90,6 +124,32 @@ public struct WorkspaceFormView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section("AI Gateway") { + TextField("Base URL (e.g., https://corveil.io)", text: $gatewayBaseURL) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + + Text("Custom Headers") + .font(.caption) + .foregroundStyle(.secondary) + TextEditor(text: $gatewayHeadersText) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 60) + Text("One `Name: Value` per line. A value starting with `op://` is resolved at launch via the 1Password CLI and kept out of config.json (the resolved value is cached owner-only in the worktree's settings.local.json). Any other value is stored in plain text in config.json — anyone with read access can see it; prefer an `op://` reference for production keys.") + .font(.caption) + .foregroundStyle(.secondary) + + if let error = gatewayValidationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Text("When set, `claude` launches in this workspace route through this gateway (ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS). Leave empty to use the vanilla Anthropic API. Does not affect the Manager session — set that under Automation.") + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) .safeAreaInset(edge: .bottom) { @@ -118,16 +178,17 @@ public struct WorkspaceFormView: View { host: provider == "gitlab" && !host.isEmpty ? host : nil, alwaysInclude: alwaysInclude, autoReviewRepos: autoReviewRepos, - customInstructions: trimmedInstructions.isEmpty ? nil : trimmedInstructions + customInstructions: trimmedInstructions.isEmpty ? nil : trimmedInstructions, + gateway: gatewayForSave ) onSave(ws) dismiss() } - .disabled(nameValidationError != nil) + .disabled(nameValidationError != nil || gatewayValidationError != nil) .keyboardShortcut(.defaultAction) } .padding() } - .frame(width: 440, height: 520) + .frame(width: 440, height: 640) } } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 10a18278..327745c0 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -113,6 +113,7 @@ final class SessionService { // be correct before preInitialize runs. Built via the shared // `managerCommand` helper so the name flow has one source. let rebuiltCommand = managerCommand(sessionName: session.name) + writeManagerGatewayEnv() for i in terminals.indices { if let cmd = terminals[i].command, cmd.contains("claude") { // Mutate in place rather than reconstructing the row: @@ -500,6 +501,21 @@ final class SessionService { } } + // Resolve and apply the workspace's AI gateway for Claude sessions + // (CROW-402). Write the resolved env block into the worktree's + // settings.local.json so manual `claude` re-runs inherit it, and build a + // launch-line prefix for the initial launch. Always called (resolved or + // nil) so switching a workspace off its gateway clears the stale env + // keys. Gated to the Claude agent — the ANTHROPIC_* vars are + // Claude-specific; the Manager uses `managerGateway` instead. + var gatewayPrefix = "" + if agent.kind == .claudeCode { + let gatewayResolved = workspaceGatewayResolved(for: sessionID) + ClaudeHookConfigWriter.writeGatewayEnv( + dirPath: worktree.worktreePath, resolved: gatewayResolved) + gatewayPrefix = ClaudeLaunchArgs.gatewayEnvPrefix(gatewayResolved) + } + let rcEnabled = appState.remoteControlEnabled // Jobs are unattended, so opt-in (default-on) auto-permission mode lets // their prompts run crow/gh/git without per-call approval. Scoped to @@ -524,9 +540,11 @@ final class SessionService { return } // Route through TerminalRouter so tmux-backed terminals get the text - // via tmux send-keys. + // via tmux send-keys. The gateway prefix (empty for non-Claude agents) + // is prepended here so it composes in front of any OTEL `export … &&` + // prefix the agent baked into `command` (CROW-402). if let routedTerminal = appState.terminals[sessionID]?.first(where: { $0.id == terminalID }) { - TerminalRouter.send(routedTerminal, text: command) + TerminalRouter.send(routedTerminal, text: gatewayPrefix + command) } else { NSLog("[SessionService] launchAgent: no terminal record for \(terminalID); cannot send") } @@ -656,11 +674,60 @@ final class SessionService { func managerCommand(sessionName: String) -> String { // Find the real claude binary (skip CMUX wrapper) let claudePath = Self.findClaudeBinary() ?? "claude" - return claudePath + ClaudeLaunchArgs.argsSuffix( + let suffix = ClaudeLaunchArgs.argsSuffix( remoteControl: appState.remoteControlEnabled, sessionName: sessionName, autoPermissionMode: appState.managerAutoPermissionMode ) + // CROW-402: prefix the command with the Manager's own gateway + // (AppConfig.managerGateway) so the initial launch overrides any global + // ~/.zshrc export. The matching settings.local.json `env` block (for + // manual re-runs) is written by the terminal-creation / hydrate paths, + // which own the devRoot write site — keeping this builder pure. + return ClaudeLaunchArgs.gatewayEnvPrefix(managerGatewayResolved()) + claudePath + suffix + } + + /// Write the Manager's gateway `env` block to `{devRoot}/.claude/settings.local.json` + /// (or clear it when unset) so manual `claude` re-runs in the Manager terminal + /// inherit the same routing as the initial launch (CROW-402). + private func writeManagerGatewayEnv() { + guard let devRoot = ConfigStore.loadDevRoot() else { return } + ClaudeHookConfigWriter.writeGatewayEnv(dirPath: devRoot, resolved: managerGatewayResolved()) + } + + /// Resolve the Manager's own AI gateway (`AppConfig.managerGateway`) from + /// disk, or nil when unset/empty (CROW-402). + private func managerGatewayResolved() -> GatewayResolver.Resolved? { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot), + let gateway = config.managerGateway, !gateway.isEmpty + else { return nil } + return GatewayResolver.resolve(gateway) + } + + /// Resolve the AI gateway for a non-Manager session from its worktree's + /// workspace (CROW-402). The worktree lives at `{devRoot}/{workspace}/…`, so + /// the workspace folder name is the first path component under devRoot. + /// Returns nil when there's no matching workspace or no (non-empty) gateway. + private func workspaceGatewayResolved(for sessionID: UUID) -> GatewayResolver.Resolved? { + guard let devRoot = ConfigStore.loadDevRoot(), + let config = ConfigStore.loadConfig(devRoot: devRoot), + let worktree = appState.primaryWorktree(for: sessionID), + let wsName = Self.workspaceName(forWorktreePath: worktree.worktreePath, devRoot: devRoot), + let workspace = config.workspaces.first(where: { $0.name == wsName }), + let gateway = workspace.gateway, !gateway.isEmpty + else { return nil } + return GatewayResolver.resolve(gateway) + } + + /// Derive the workspace folder name from a worktree path: + /// `{devRoot}/{workspace}/{repo-folder}` → `{workspace}`. Pure path math. + static func workspaceName(forWorktreePath path: String, devRoot: String) -> String? { + let root = (devRoot as NSString).standardizingPath + let full = (path as NSString).standardizingPath + guard full.hasPrefix(root + "/") else { return nil } + let relative = String(full.dropFirst(root.count + 1)) + return relative.split(separator: "/").first.map(String.init) } /// Create the single Claude-Code terminal for a Manager session and persist @@ -672,6 +739,10 @@ final class SessionService { @discardableResult private func createManagerTerminal(sessionID: UUID, sessionName: String, cwd: String) -> SessionTerminal { let command = managerCommand(sessionName: sessionName) + // CROW-402: write the Manager gateway env block to {devRoot}/.claude so + // manual `claude` re-runs in this terminal inherit the same routing. The + // Manager's cwd is the devRoot. + ClaudeHookConfigWriter.writeGatewayEnv(dirPath: cwd, resolved: managerGatewayResolved()) let rawTerminal = SessionTerminal( sessionID: sessionID, name: sessionName, diff --git a/docs/configuration.md b/docs/configuration.md index 0e63853e..2fa89ac3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,13 @@ All persistent state lives under `~/Library/Application Support/crow/` (see `Pac "provider": "github", "cli": "gh", "host": null, - "customInstructions": "Always run npm test before committing" + "customInstructions": "Always run npm test before committing", + "gateway": { + "baseURL": "https://corveil.io", + "customHeaders": { + "x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key" + } + } }, { "id": "uuid", @@ -63,15 +69,69 @@ All persistent state lives under `~/Library/Application Support/crow/` (see `Pac - **`excludeReviewRepos`** — repos to hide from the review board (e.g., `["zarf-dev/zarf"]`). Supports `*` wildcards (e.g., `"zarf-dev/*"`). Matching reviews are filtered out from the board, sidebar badge count, and notifications. Editable in Settings → Automation → Reviews. - **`excludeTicketRepos`** — repos to hide from the ticket board (e.g., `["zarf-dev/zarf"]`). Supports `*` wildcards (e.g., `"zarf-dev/*"`). Matching issues are filtered out from the board, pipeline counts, and auto-create candidates. Editable in Settings → Automation → Tickets. - **`customInstructions`** — optional free-text instructions appended to the session prompt as a `## Custom Instructions` section. Use this for workspace-specific conventions, e.g., "Always run `npm test` before committing" or "Use the auth middleware in `src/middleware/auth.ts` as a pattern." +- **`gateway`** — optional AI gateway for this workspace's `claude` launches. See [AI Gateway](#ai-gateway) below. For the full set of automation toggles backed by this config, see [automation.md](automation.md). +## AI Gateway + +A workspace can route its Claude Code sessions through a proxy/gateway (e.g. an internal LLM gateway) instead of the vanilla Anthropic API, with its own API key. This replaces setting `ANTHROPIC_BASE_URL` / `ANTHROPIC_CUSTOM_HEADERS` globally in your shell — which would force *every* `claude` on the machine through one gateway — with a per-workspace setting Crow manages. + +```jsonc +"gateway": { + "baseURL": "https://corveil.io", + "customHeaders": { + // op:// reference — resolved at launch via the 1Password CLI; kept out of config.json + "x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key" + // or a plaintext value (stored in config.json — see the security note) + // "x-citadel-api-key": "Bearer sk-citadel-…" + } +} +``` + +- **`baseURL`** — exported as `ANTHROPIC_BASE_URL` for the workspace's `claude` launches. +- **`customHeaders`** — a `Name: Value` map exported as `ANTHROPIC_CUSTOM_HEADERS` (newline-separated). Both fields must be set together; a `baseURL` with no headers (or vice versa) is rejected when the config is loaded. + +When a workspace has a `gateway`, Crow injects these vars two ways so they apply on the initial launch *and* survive manual `claude` re-runs: + +1. **Launch line** — the `claude` invocation is prefixed with the env-var assignments, overriding any global `~/.zshrc` export for that launch. (When a workspace has multiple headers, the header value can't go on the line — an embedded newline would submit the command early — so it's carried by `settings.local.json` and the launch line instead `unset`s any inherited `ANTHROPIC_CUSTOM_HEADERS` so the gateway's `baseURL` is never paired with stale global headers.) +2. **`settings.local.json`** — the resolved values are written to the worktree's `.claude/settings.local.json` `env` block (gitignored, mode `0600`), which Claude Code reads on every run. + +When a workspace has **no** `gateway`, Crow instead prefixes the launch with `unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS` so a global shell export — or a sibling workspace's gateway — can't bleed into it. Edit a workspace's gateway in **Settings → Workspaces**. + +> **Precedence note:** the launch-line assignment is what reliably overrides a value exported by your shell for the initial launch. Whether Claude Code's `settings.local.json` `env` block *alone* overrides an inherited shell variable (e.g. an `ANTHROPIC_BASE_URL` still left in `~/.zshrc`) is not something Crow controls — so the intended end state is to delete the global `~/.zshrc` exports once per-workspace gateways are configured, leaving `config.json` the single source of truth. + +### Secret storage + +A header value can be either: + +- **An `op://` reference** (recommended) — resolved at session launch via the 1Password CLI (`op read`). The secret is **never written to `config.json`**. Requires `op` installed and signed in; a failed lookup drops that header and logs a redacted warning (the gateway then rejects the request rather than silently falling back to the vanilla API). +- **A plaintext value** — stored as-is in `config.json` (mode `0600`). Convenient for local dev, but **anyone with read access to the file can see the key**. The Settings UI shows a warning. Prefer an `op://` reference for production keys. + +`op://` keeps secrets out of `config.json` — but note it does **not** mean "no secret on disk." The *resolved* value is written into the worktree's `.claude/settings.local.json` `env` block (so manual re-runs inherit it) and cached there for the worktree's lifetime. That file is gitignored and written `0600` (owner-only), the same protection `config.json` gets. Resolved secret values are never logged. + +### Manager gateway + +The Manager session sits at the dev root and isn't bound to a single workspace, so it has its **own** top-level gateway rather than inheriting any one workspace's: + +```jsonc +{ + "managerGateway": { + "baseURL": "https://corveil.io", + "customHeaders": { "x-citadel-api-key": "op://Spotlight Prod/Citadel/api_key" } + } +} +``` + +Same shape, same secret-storage rules, same two-way injection (written to `{devRoot}/.claude/settings.local.json`). Configure it under **Settings → Automation → Manager AI Gateway**. Takes effect on the next app launch. + ## Manager Terminal The Manager tab runs Claude Code at the dev root and drives workspace orchestration. Its behavior is controlled by these top-level keys in `{devRoot}/.claude/config.json`: - **`managerAutoPermissionMode`** (default: `true`) — passes `--permission-mode auto` to the Manager's `claude` launch so it can run `crow`, `gh`, and `git` commands without per-call approval. Requires Claude Code **v2.1.83+**, a **Max / Team / Enterprise / API** plan, the **Anthropic** API provider (not Bedrock / Vertex / Foundry), and a supported model (**Sonnet 4.6**, **Opus 4.6**, or **Opus 4.7**). On Team/Enterprise plans an admin must enable auto mode in Claude Code admin settings. Turn this off via **Settings → Automation → Manager Terminal** if your account reports auto mode as unavailable. Worker sessions and CLI-spawned terminals are unaffected by this setting. - **`remoteControlEnabled`** (default: `false`) — launches new Claude Code sessions with `--rc` so you can control them from claude.ai or the Claude mobile app. +- **`managerGateway`** — optional AI gateway for the Manager's `claude` launch, with its own API key. See [Manager gateway](#manager-gateway). Changes take effect on next app launch — the Manager's stored command is rebuilt on hydration. diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index bc9463b4..dd289f22 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -44,6 +44,12 @@ BASE_BRANCH="" # Runtime state TERMINAL_ID="" +# Resolved AI gateway for this workspace (populated by resolve_gateway_env). +WS_BASE_URL="" +WS_CUSTOM_HEADERS="" +WS_HAS_GATEWAY=false +WS_GATEWAY_RESOLVED=false + # ─── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[setup.sh] $*" >&2; } @@ -97,6 +103,72 @@ is_attribution_trailers_enabled() { return 0 } +# Resolve this workspace's AI gateway from {devRoot}/.claude/config.json (CROW-402). +# Populates WS_BASE_URL / WS_CUSTOM_HEADERS and sets WS_HAS_GATEWAY=true when a +# gateway is configured. Header values prefixed `op://` are resolved via the +# 1Password CLI (`op read`); any other value is used literally. Idempotent — the +# expensive `op read` only runs once. Never logs the resolved header values. +resolve_gateway_env() { + [[ "$WS_GATEWAY_RESOLVED" == true ]] && return 0 + WS_GATEWAY_RESOLVED=true + + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + command -v jq >/dev/null 2>&1 || { log "jq not found; skipping gateway resolution"; return 0; } + + local gateway + gateway=$(jq -c --arg name "$WORKSPACE" \ + '.workspaces[]? | select(.name == $name) | .gateway // empty' \ + "$config_path" 2>/dev/null) || return 0 + [[ -n "$gateway" && "$gateway" != "null" ]] || return 0 + + local base_url + base_url=$(jq -r '.baseURL // ""' <<< "$gateway") + [[ -n "$base_url" ]] || return 0 + + # Resolve each header value and join as newline-separated "Name: Value". + local headers="" name value resolved + while IFS= read -r name; do + [[ -n "$name" ]] || continue + value=$(jq -r --arg k "$name" '.customHeaders[$k]' <<< "$gateway") + if [[ "$value" == op://* ]]; then + if ! resolved=$(op read "$value" 2>/dev/null); then + log "Gateway: failed to resolve secret reference for header '$name' (op read failed); dropping it" + continue + fi + value="$resolved" + fi + [[ -n "$headers" ]] && headers+=$'\n' + headers+="$name: $value" + done < <(jq -r '.customHeaders | keys[]' <<< "$gateway" 2>/dev/null) + + WS_BASE_URL="$base_url" + WS_CUSTOM_HEADERS="$headers" + WS_HAS_GATEWAY=true + log "Gateway: routing this workspace through $base_url" +} + +# Build the shell prefix that applies (or clears) the gateway env vars on the +# `claude` launch line — mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. +# Gateway absent → `unset … && ` so a no-gateway workspace doesn't inherit a +# sibling's or ~/.zshrc's gateway. Single header → `ANTHROPIC_BASE_URL='…' +# ANTHROPIC_CUSTOM_HEADERS='…' `. 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 settings.local.json carries it; we still `unset ANTHROPIC_CUSTOM_HEADERS` +# so the gateway's baseURL is never paired with a stale ~/.zshrc-inherited header. +gateway_launch_prefix() { + if [[ "$WS_HAS_GATEWAY" != true ]]; then + printf 'unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ' + return 0 + fi + if [[ "$WS_CUSTOM_HEADERS" == *$'\n'* ]]; then + printf 'unset ANTHROPIC_CUSTOM_HEADERS && ANTHROPIC_BASE_URL=%s ' "$(posix_quote "$WS_BASE_URL")" + return 0 + fi + printf 'ANTHROPIC_BASE_URL=%s ANTHROPIC_CUSTOM_HEADERS=%s ' \ + "$(posix_quote "$WS_BASE_URL")" "$(posix_quote "$WS_CUSTOM_HEADERS")" +} + die() { local step="$1" msg="$2" local partial="" @@ -364,17 +436,32 @@ create_session() { # Write a per-worktree .claude/settings.local.json that overrides Claude Code's # attribution.commit so commits include a `Crow-Session: ` trailer -# alongside the standard `Co-Authored-By: Claude` line. Runs for every -# worktree (primary and secondary) regardless of --skip-launch, so any worktree -# the user later opens with Claude Code picks up the override. +# alongside the standard `Co-Authored-By: Claude` line, and — when this workspace +# has an AI gateway (CROW-402) — an `env` block so manual `claude` re-runs in the +# terminal inherit the gateway. Runs for every worktree (primary and secondary) +# regardless of --skip-launch, so any worktree the user later opens with Claude +# Code picks up both overrides. write_settings_local() { - if ! is_attribution_trailers_enabled; then - log "Attribution trailers disabled via config; skipping settings.local.json" + # Resolve the gateway first so its env block is written even when attribution + # trailers are disabled. + resolve_gateway_env + + local want_attribution=false + if is_attribution_trailers_enabled && [[ -n "$SESSION_ID" ]]; then + want_attribution=true + elif [[ -z "$SESSION_ID" ]]; then + log "Warning: SESSION_ID not set, skipping attribution trailer" + else + log "Attribution trailers disabled via config" + fi + + if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true ]]; then + log "No attribution trailer or gateway to write; skipping settings.local.json" return fi - if [[ -z "$SESSION_ID" ]]; then - log "Warning: SESSION_ID not set, skipping settings.local.json" + if ! command -v jq >/dev/null 2>&1; then + log "jq not found; skipping settings.local.json" return fi @@ -382,16 +469,39 @@ write_settings_local() { local settings_path="$settings_dir/settings.local.json" mkdir -p "$settings_dir" - # The newlines inside the "commit" string are literal \n escapes in JSON; - # the heredoc passes them through to the file as the two-character sequence. - cat > "$settings_path" <\\nCrow-Session: $SESSION_ID" - } -} -EOF - log "Wrote attribution settings to $settings_path" + # Merge into existing settings (preserving hooks etc.) via jq, which handles + # JSON escaping of the newlines in the commit trailer and the header values. + local base="{}" + [[ -f "$settings_path" ]] && base=$(cat "$settings_path") + + local commit_trailer="🐦‍⬛ Generated with Claude Code, orchestrated by Crow + +Co-Authored-By: Claude +Crow-Session: $SESSION_ID" + + local merged + if ! merged=$(jq \ + --argjson want_attr "$want_attribution" \ + --arg commit "$commit_trailer" \ + --argjson want_gw "$WS_HAS_GATEWAY" \ + --arg base_url "$WS_BASE_URL" \ + --arg headers "$WS_CUSTOM_HEADERS" \ + '(if $want_attr then .attribution.commit = $commit else . end) + | (if $want_gw then .env.ANTHROPIC_BASE_URL = $base_url + | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end)' \ + <<< "$base"); then + die "settings_local" "jq failed to build settings.local.json" + fi + printf '%s\n' "$merged" > "$settings_path" + # The env block can carry a resolved bearer token, so restrict the file to + # owner-only — matching ConfigStore's 0600 on config.json. + chmod 600 "$settings_path" 2>/dev/null || true + + if [[ "$WS_HAS_GATEWAY" == true ]]; then + log "Wrote settings.local.json (attribution + gateway env) to $settings_path" + else + log "Wrote attribution settings to $settings_path" + fi # Belt-and-suspenders: add the file to the per-worktree git exclude so it # is never accidentally committed even if the repo's .gitignore does not @@ -578,7 +688,17 @@ launch_claude() { rc_args=" --rc --name $(posix_quote "$SESSION_NAME")" log "Remote control enabled — launching with --rc --name '$SESSION_NAME'" fi - local launch_cmd="cd $WORKTREE_PATH && $claude_bin --permission-mode plan$rc_args \"\$(cat $prompt_path)\"" + # CROW-402: prefix the launch line with the workspace gateway env (or `unset` + # when there's none) so the deferred launch overrides any global ~/.zshrc + # export. resolve_gateway_env is idempotent (it already ran in + # write_settings_local), so this reuses its result without a second `op read`. + # The assignments are intentionally not logged (the header value is a bearer + # token). Placed immediately before $claude_bin so the command-prefix + # assignments bind to claude. + resolve_gateway_env + local gw_prefix + gw_prefix=$(gateway_launch_prefix) + local launch_cmd="cd $WORKTREE_PATH && ${gw_prefix}$claude_bin --permission-mode plan$rc_args \"\$(cat $prompt_path)\"" # Create terminal with the launch command attached. log "Creating terminal (deferred agent launch via --command)..." @@ -665,4 +785,8 @@ main() { emit_result } -main "$@" +# Only run when executed directly — sourcing (e.g. from tests) exposes the +# functions without kicking off a full workspace setup. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/skills/crow-workspace/setup_gateway_test.sh b/skills/crow-workspace/setup_gateway_test.sh new file mode 100755 index 00000000..00179ae4 --- /dev/null +++ b/skills/crow-workspace/setup_gateway_test.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Unit tests for the AI-gateway helpers in setup.sh (CROW-402). +# +# Sources setup.sh (the bottom `main` is guarded so sourcing is side-effect free) +# and exercises resolve_gateway_env / gateway_launch_prefix / write_settings_local +# against a synthetic config.json, with a fake `op` on PATH so no real 1Password +# lookup happens. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SETUP_SH="$SCRIPT_DIR/setup.sh" + +pass=0; fail=0 +check() { # check + if [[ "$2" == "$3" ]]; then + pass=$((pass+1)); echo " ok: $1" + else + fail=$((fail+1)); echo " FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]" + fi +} +contains() { # contains + if [[ "$2" == *"$3"* ]]; then + pass=$((pass+1)); echo " ok: $1" + else + fail=$((fail+1)); echo " FAIL: $1"; echo " [$2] does not contain [$3]" + fi +} + +# Fake `op` that resolves any reference to a fixed marker. +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" +cat > "$TMP/bin/op" <<'OP' +#!/usr/bin/env bash +# fake `op read ` +echo "RESOLVED-SECRET-for-$2" +OP +chmod +x "$TMP/bin/op" +export PATH="$TMP/bin:$PATH" + +# Synthetic devRoot + config.json. +DEV_ROOT="$TMP/devroot" +mkdir -p "$DEV_ROOT/.claude" +cat > "$DEV_ROOT/.claude/config.json" <<'JSON' +{ + "workspaces": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "RadiusMethod", + "provider": "github", + "cli": "gh", + "gateway": { + "baseURL": "https://corveil.io", + "customHeaders": { + "x-citadel-api-key": "op://Vault/Citadel/api_key", + "x-plain": "literal-value" + } + } + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "name": "Personal", + "provider": "github", + "cli": "gh" + } + ] +} +JSON + +# Source the helpers (main is guarded). NOTE: sourcing setup.sh runs its +# top-level global initializers (DEV_ROOT="", WORKSPACE="", …), so any globals +# the tests rely on must be assigned *after* this line. +# shellcheck disable=SC1090 +source "$SETUP_SH" +DEV_ROOT="$TMP/devroot" + +echo "== gateway-present workspace ==" +WORKSPACE="RadiusMethod" +WORKTREE_PATH="$DEV_ROOT/RadiusMethod/repo-1-slug" +SESSION_ID="ABCD-1234" +WS_GATEWAY_RESOLVED=false; WS_HAS_GATEWAY=false; WS_BASE_URL=""; WS_CUSTOM_HEADERS="" + +resolve_gateway_env +check "WS_HAS_GATEWAY true" "true" "$WS_HAS_GATEWAY" +check "WS_BASE_URL" "https://corveil.io" "$WS_BASE_URL" +contains "op:// header resolved via op read" "$WS_CUSTOM_HEADERS" "x-citadel-api-key: RESOLVED-SECRET-for-op://Vault/Citadel/api_key" +contains "plaintext header passed through" "$WS_CUSTOM_HEADERS" "x-plain: literal-value" + +prefix=$(gateway_launch_prefix) +contains "launch prefix sets baseURL" "$prefix" "ANTHROPIC_BASE_URL='https://corveil.io'" +# Two headers → the value has an embedded newline, so it is carried by +# settings.local.json rather than the launch line; the prefix still unsets any +# inherited ANTHROPIC_CUSTOM_HEADERS so the baseURL isn't paired with a stale +# ~/.zshrc header, and must not contain a literal newline. +contains "multi-header prefix unsets inherited headers" "$prefix" "unset ANTHROPIC_CUSTOM_HEADERS && " +check "multi-header prefix omits headers assignment" "0" "$([[ "$prefix" == *"ANTHROPIC_CUSTOM_HEADERS='"* ]] && echo 1 || echo 0)" +check "launch prefix has no embedded newline" "0" "$([[ "$prefix" == *$'\n'* ]] && echo 1 || echo 0)" + +mkdir -p "$WORKTREE_PATH" +# Not a git repo → the git-exclude step skips gracefully (rev-parse fails); the +# settings.local.json write still happens, which is what we assert. +write_settings_local +settings="$WORKTREE_PATH/.claude/settings.local.json" +check "settings.local.json written" "yes" "$([[ -f "$settings" ]] && echo yes || echo no)" +contains "env.ANTHROPIC_BASE_URL present" "$(cat "$settings")" '"ANTHROPIC_BASE_URL": "https://corveil.io"' +contains "attribution preserved alongside env" "$(cat "$settings")" '"attribution"' +# Resolved secret must be present in the file (settings.local.json is the at-rest +# store for re-runs) but the bearer reference scheme must be gone. +contains "resolved secret in env" "$(cat "$settings")" "RESOLVED-SECRET-for-op://Vault/Citadel/api_key" +contains "both headers serialized in env" "$(cat "$settings")" "x-plain: literal-value" +# The file caches a resolved bearer token, so it must be owner-only (0600). +check "settings.local.json is 0600" "600" "$(stat -f '%Lp' "$settings" 2>/dev/null || stat -c '%a' "$settings")" + +echo "== gateway-absent workspace ==" +WORKSPACE="Personal" +WS_GATEWAY_RESOLVED=false; WS_HAS_GATEWAY=false; WS_BASE_URL=""; WS_CUSTOM_HEADERS="" +resolve_gateway_env +check "WS_HAS_GATEWAY false" "false" "$WS_HAS_GATEWAY" +prefix=$(gateway_launch_prefix) +check "launch prefix unsets" "unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && " "$prefix" + +echo "== single-header launch prefix ==" +WS_HAS_GATEWAY=true; WS_BASE_URL="https://corveil.io"; WS_CUSTOM_HEADERS="x-key: Bearer sk-1" +prefix=$(gateway_launch_prefix) +check "single header on launch line" \ + "ANTHROPIC_BASE_URL='https://corveil.io' ANTHROPIC_CUSTOM_HEADERS='x-key: Bearer sk-1' " \ + "$prefix" + +echo +echo "passed: $pass, failed: $fail" +[[ "$fail" -eq 0 ]]