From 20d4c1fbf873108dad22ba8f4eacbf7ad2e1a025 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:08:04 -0500 Subject: [PATCH 1/7] Add WorkspaceGateway model with per-workspace + manager gateway config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional AI gateway to the config so `claude` launches can route through a per-workspace ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS instead of a global ~/.zshrc export (CROW-402): - WorkspaceGateway { baseURL, customHeaders }; validating decode rejects a half-filled block (baseURL xor customHeaders) at parse time. - WorkspaceInfo.gateway and AppConfig.managerGateway, both optional/default-nil so existing configs decode unchanged. The Manager has its own gateway because it sits at devRoot and isn't bound to one workspace. - Tests: legacy decode → nil, round-trip through encode/decode and ConfigStore disk I/O, malformed-block rejection surfaces as a load failure. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- .../Sources/CrowCore/Models/AppConfig.swift | 82 ++++++++++++++++++- .../Tests/CrowCoreTests/AppConfigTests.swift | 80 ++++++++++++++++++ .../ConfigStoreTests.swift | 47 +++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 89d02d61..ea7cf2c7 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,61 @@ 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 + } +} + /// 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 +244,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 +265,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 +276,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 +289,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..1154b3f5 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -408,3 +408,83 @@ 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) + } +} 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) +} From c8c322b5c33baa0338fb68a2bd8e78c5ba67be61 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:09:55 -0500 Subject: [PATCH 2/7] Add GatewayResolver: resolve op:// refs + serialize launch env values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayResolver turns a WorkspaceGateway into launch-ready ANTHROPIC_BASE_URL and ANTHROPIC_CUSTOM_HEADERS values (CROW-402): - Header values prefixed `op://` are resolved via the 1Password CLI (`op read`, bounded by a 15s timeout); any other value is used literally (plaintext). - Headers serialize to newline-separated `Name: Value`, sorted for determinism — the format Claude Code expects for ANTHROPIC_CUSTOM_HEADERS. - A failed secret resolution drops that header (logged, redacted) but keeps the baseURL, so the gateway rejects the request loudly rather than silently falling back to the vanilla Anthropic API. Resolved secrets are never logged. - `resolveSecret` is injectable, so tests cover plaintext/op/failure paths without shelling out. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- .../Sources/CrowCore/GatewayResolver.swift | 116 ++++++++++++++++++ .../CrowCoreTests/GatewayResolverTests.swift | 60 +++++++++ 2 files changed, 176 insertions(+) create mode 100644 Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift 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/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") +} From 38c0afbba2f12236aa4b9dcc7be343d452efd3a9 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:21:11 -0500 Subject: [PATCH 3/7] Inject per-workspace + manager gateway into app-launched claude sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the resolved gateway into the two app-side launch paths (CROW-402): - HookConfigGenerator.writeGatewayEnv merges (or clears) an `env` block in a directory's .claude/settings.local.json, so manual `claude` re-runs in the terminal inherit the gateway — not just the initial launch. - ClaudeLaunchArgs.gatewayEnvPrefix builds the launch-line prefix: command-prefix assignments when a gateway is present (overriding ~/.zshrc for that launch), or `unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS &&` when absent so a no-gateway workspace can't inherit a sibling's or ~/.zshrc's gateway. Multi-line header values are omitted from the line (settings.local.json carries them) to avoid pasting a newline that would submit the command early. - launchClaude resolves the session's workspace by the worktree's first path component under devRoot; managerCommand uses AppConfig.managerGateway. Both write the settings.local.json env block and prepend the prefix. Tests cover the prefix forms; the existing managerCommand test still passes. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- .../CrowClaude/ClaudeHookConfigWriter.swift | 60 ++++++++++++++ .../Sources/CrowClaude/ClaudeLaunchArgs.swift | 33 ++++++++ .../CrowCoreTests/GatewayResolverTests.swift | 28 +++++++ Sources/Crow/App/SessionService.swift | 78 ++++++++++++++++++- 4 files changed, 196 insertions(+), 3 deletions(-) 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/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift index 26080270..0df141ae 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift @@ -58,3 +58,31 @@ import Testing #expect(resolved?.baseURL == "https://corveil.io") #expect(resolved?.customHeaders == "x-plain: kept") } + +// MARK: - Launch-line prefix (ClaudeLaunchArgs.gatewayEnvPrefix) + +@Test func gatewayEnvPrefixUnsetsWhenNil() throws { + #expect(ClaudeLaunchArgs.gatewayEnvPrefix(nil) == "unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ") +} + +@Test func gatewayEnvPrefixAssignsSingleHeader() throws { + let resolved = GatewayResolver.Resolved( + baseURL: "https://corveil.io", + customHeaders: "x-citadel-api-key: Bearer sk-1" + ) + let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved) + #expect(prefix == "ANTHROPIC_BASE_URL='https://corveil.io' ANTHROPIC_CUSTOM_HEADERS='x-citadel-api-key: Bearer sk-1' ") +} + +@Test func gatewayEnvPrefixOmitsMultiLineHeadersFromLine() throws { + // A multi-header value has an embedded newline; pasting it onto the launch + // line would submit the command early, so it's omitted (settings.local.json + // carries it). baseURL is still set. + let resolved = GatewayResolver.Resolved( + baseURL: "https://corveil.io", + customHeaders: "x-a: one\nx-b: two" + ) + let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved) + #expect(prefix == "ANTHROPIC_BASE_URL='https://corveil.io' ") + #expect(!prefix.contains("\n")) +} diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 10a18278..d129dc64 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,22 @@ 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 +541,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 +675,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 +740,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, From 4082e98aa6d173e8f7d89fbe53050185221edcab Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:26:26 -0500 Subject: [PATCH 4/7] setup.sh: route workspace claude launches through the AI gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the per-workspace gateway into the crow-workspace launch path (CROW-402): - resolve_gateway_env reads the workspace's gateway from {devRoot}/.claude/config.json via jq, resolving op:// header values through `op read` (plaintext values pass through). Resolved values are never logged. - write_settings_local now merges (via jq) a gateway `env` block alongside the existing attribution trailer into settings.local.json — preserving any existing keys — so manual `claude` re-runs in the terminal inherit the gateway. The env block is written independently of the attributionTrailers setting. - The launch line is prefixed with the gateway env assignments (or `unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS &&` when no gateway), so the initial launch overrides ~/.zshrc and a no-gateway workspace can't inherit it. Multi-line header values are omitted from the line (carried by settings.local.json). - main is now guarded so the helpers can be sourced by setup_gateway_test.sh, which covers op:// resolution, the settings merge, and both prefix forms. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- skills/crow-workspace/setup.sh | 155 +++++++++++++++++--- skills/crow-workspace/setup_gateway_test.sh | 127 ++++++++++++++++ 2 files changed, 263 insertions(+), 19 deletions(-) create mode 100755 skills/crow-workspace/setup_gateway_test.sh diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index bc9463b4..798706d7 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,68 @@ 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 present → `ANTHROPIC_BASE_URL='…' ANTHROPIC_CUSTOM_HEADERS='…' ` (the +# header is omitted when multi-line, since a pasted newline would submit the +# command early — settings.local.json carries it). Absent → `unset … && ` so a +# no-gateway workspace doesn't inherit a sibling's or ~/.zshrc's gateway. +gateway_launch_prefix() { + if [[ "$WS_HAS_GATEWAY" != true ]]; then + printf 'unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ' + return 0 + fi + printf 'ANTHROPIC_BASE_URL=%s ' "$(posix_quote "$WS_BASE_URL")" + if [[ "$WS_CUSTOM_HEADERS" != *$'\n'* ]]; then + printf 'ANTHROPIC_CUSTOM_HEADERS=%s ' "$(posix_quote "$WS_CUSTOM_HEADERS")" + fi +} + die() { local step="$1" msg="$2" local partial="" @@ -364,17 +432,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 +465,36 @@ 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" + + 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 +681,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 +778,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..0a118773 --- /dev/null +++ b/skills/crow-workspace/setup_gateway_test.sh @@ -0,0 +1,127 @@ +#!/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 intentionally omitted +# from the launch line (a pasted newline would submit the command early); the +# settings.local.json env block below carries it instead. +check "launch prefix omits multi-line headers" "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" + +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 ]] From 7fee660a710b50f26cd2b483b9b86e3252bede61 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:29:56 -0500 Subject: [PATCH 5/7] Settings UI: per-workspace + Manager AI gateway editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gateway configuration to Settings (CROW-402): - WorkspaceFormView gains an "AI Gateway" section (Base URL + a Name: Value headers editor) with an inline note that op:// references resolve at launch while plaintext is stored in config.json. Save is blocked on a half-filled block, matching the decoder's parse-time validation. - AutomationSettingsView gains a "Manager AI Gateway" section bound to AppConfig.managerGateway, beside the Manager auto-permission toggle, since the Manager has its own gateway. - WorkspaceGateway.parseHeaderLines / headerLines convert between the dict and the multiline editor text (shared by both views), with tests. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- .../Sources/CrowCore/Models/AppConfig.swift | 27 ++++++++ .../Tests/CrowCoreTests/AppConfigTests.swift | 22 ++++++ .../CrowUI/AutomationSettingsView.swift | 62 +++++++++++++++++ .../CrowUI/Sources/CrowUI/SettingsView.swift | 1 + .../Sources/CrowUI/WorkspaceFormView.swift | 67 ++++++++++++++++++- 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index ea7cf2c7..b9a3ad59 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -197,6 +197,33 @@ public struct WorkspaceGateway: Codable, Sendable, Equatable { } } +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 diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 1154b3f5..b9bbbf8a 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -488,3 +488,25 @@ import Testing _ = 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/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index eda77775..58cb7654 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; 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..b40e438e 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. 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) } } From a73cfa5b385d6864aa59a412416f1f28c12dd784 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:31:02 -0500 Subject: [PATCH 6/7] docs: document the per-workspace and Manager AI gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "AI Gateway" section to configuration.md covering the workspace `gateway` and top-level `managerGateway` blocks (CROW-402): the op:// vs plaintext secret storage rules, the both-fields-or-neither validation, and the two-way injection (launch line + settings.local.json) that makes routing survive manual re-runs and keeps a no-gateway workspace from inheriting a global ~/.zshrc export. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- docs/configuration.md | 60 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0e63853e..65fc4d82 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,67 @@ 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, never stored at rest + "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. +2. **`settings.local.json`** — the resolved values are written to the worktree's `.claude/settings.local.json` `env` block (gitignored), 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**. + +### 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. + +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. From d8bc867f8d649c247ce16badbb51b27c594aecd4 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 1 Jun 2026 11:44:30 -0500 Subject: [PATCH 7/7] Address review: close multi-header leak path + 0600 settings.local.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Yellow findings from the PR #403 review (CROW-402): Y1 — Multi-header launch line no longer leaves a ~/.zshrc leak. When a gateway has more than one header the value can't go on the launch line (embedded newline), so it was carried only by settings.local.json — but the prefix was a bare `ANTHROPIC_BASE_URL=… claude`, which doesn't clear an inherited ANTHROPIC_CUSTOM_HEADERS. The multi-header branch now emits `unset ANTHROPIC_CUSTOM_HEADERS && ANTHROPIC_BASE_URL=… ` in both ClaudeLaunchArgs.gatewayEnvPrefix and setup.sh's gateway_launch_prefix, so the gateway's baseURL is never paired with stale global headers. Tests updated. Y2 — Resolved secrets in settings.local.json are now written 0600. The env block caches a post-`op read` bearer token, so HookConfigGenerator.writeGatewayEnv and setup.sh's write_settings_local now chmod the file owner-only, matching ConfigStore's 0600 on config.json. Added a permission assertion to the bash test. Also softened the docs / Settings UI copy: `op://` means "kept out of config.json," not "no secret on disk" — the resolved value is cached (0600) in the worktree's settings.local.json. Added a precedence note to configuration.md (G1): the launch-line assignment is what reliably overrides a shell export; the end state is to delete the global ~/.zshrc exports. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9 --- .../ClaudeLaunchArgsTests.swift | 38 +++++++++++++++++++ .../CrowCoreTests/GatewayResolverTests.swift | 28 -------------- .../CrowUI/AutomationSettingsView.swift | 2 +- .../Sources/CrowUI/WorkspaceFormView.swift | 2 +- Sources/Crow/App/SessionService.swift | 1 - docs/configuration.md | 10 +++-- skills/crow-workspace/setup.sh | 21 ++++++---- skills/crow-workspace/setup_gateway_test.sh | 12 ++++-- 8 files changed, 68 insertions(+), 46 deletions(-) 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/Tests/CrowCoreTests/GatewayResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift index 0df141ae..26080270 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/GatewayResolverTests.swift @@ -58,31 +58,3 @@ import Testing #expect(resolved?.baseURL == "https://corveil.io") #expect(resolved?.customHeaders == "x-plain: kept") } - -// MARK: - Launch-line prefix (ClaudeLaunchArgs.gatewayEnvPrefix) - -@Test func gatewayEnvPrefixUnsetsWhenNil() throws { - #expect(ClaudeLaunchArgs.gatewayEnvPrefix(nil) == "unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS && ") -} - -@Test func gatewayEnvPrefixAssignsSingleHeader() throws { - let resolved = GatewayResolver.Resolved( - baseURL: "https://corveil.io", - customHeaders: "x-citadel-api-key: Bearer sk-1" - ) - let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved) - #expect(prefix == "ANTHROPIC_BASE_URL='https://corveil.io' ANTHROPIC_CUSTOM_HEADERS='x-citadel-api-key: Bearer sk-1' ") -} - -@Test func gatewayEnvPrefixOmitsMultiLineHeadersFromLine() throws { - // A multi-header value has an embedded newline; pasting it onto the launch - // line would submit the command early, so it's omitted (settings.local.json - // carries it). baseURL is still set. - let resolved = GatewayResolver.Resolved( - baseURL: "https://corveil.io", - customHeaders: "x-a: one\nx-b: two" - ) - let prefix = ClaudeLaunchArgs.gatewayEnvPrefix(resolved) - #expect(prefix == "ANTHROPIC_BASE_URL='https://corveil.io' ") - #expect(!prefix.contains("\n")) -} diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 58cb7654..1babb012 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -155,7 +155,7 @@ public struct AutomationSettingsView: View { .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; any other value is stored in plain text in config.json — prefer an `op://` reference for production keys.") + 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) diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index b40e438e..18808994 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -136,7 +136,7 @@ public struct WorkspaceFormView: View { 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. 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.") + 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) diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index d129dc64..327745c0 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -516,7 +516,6 @@ final class SessionService { 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 diff --git a/docs/configuration.md b/docs/configuration.md index 65fc4d82..2fa89ac3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,7 @@ A workspace can route its Claude Code sessions through a proxy/gateway (e.g. an "gateway": { "baseURL": "https://corveil.io", "customHeaders": { - // op:// reference — resolved at launch via the 1Password CLI, never stored at rest + // 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-…" @@ -94,11 +94,13 @@ A workspace can route its Claude Code sessions through a proxy/gateway (e.g. an 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. -2. **`settings.local.json`** — the resolved values are written to the worktree's `.claude/settings.local.json` `env` block (gitignored), which Claude Code reads on every run. +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: @@ -106,7 +108,7 @@ 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. -Resolved secret values are never logged. +`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 diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index 798706d7..dd289f22 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -150,19 +150,23 @@ resolve_gateway_env() { # Build the shell prefix that applies (or clears) the gateway env vars on the # `claude` launch line — mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. -# Gateway present → `ANTHROPIC_BASE_URL='…' ANTHROPIC_CUSTOM_HEADERS='…' ` (the -# header is omitted when multi-line, since a pasted newline would submit the -# command early — settings.local.json carries it). Absent → `unset … && ` so a -# no-gateway workspace doesn't inherit a sibling's or ~/.zshrc's gateway. +# 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 - printf 'ANTHROPIC_BASE_URL=%s ' "$(posix_quote "$WS_BASE_URL")" - if [[ "$WS_CUSTOM_HEADERS" != *$'\n'* ]]; then - printf 'ANTHROPIC_CUSTOM_HEADERS=%s ' "$(posix_quote "$WS_CUSTOM_HEADERS")" + 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() { @@ -489,6 +493,9 @@ Crow-Session: $SESSION_ID" 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" diff --git a/skills/crow-workspace/setup_gateway_test.sh b/skills/crow-workspace/setup_gateway_test.sh index 0a118773..00179ae4 100755 --- a/skills/crow-workspace/setup_gateway_test.sh +++ b/skills/crow-workspace/setup_gateway_test.sh @@ -88,10 +88,12 @@ contains "plaintext header passed through" "$WS_CUSTOM_HEADERS" "x-plain: litera 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 intentionally omitted -# from the launch line (a pasted newline would submit the command early); the -# settings.local.json env block below carries it instead. -check "launch prefix omits multi-line headers" "0" "$([[ "$prefix" == *"ANTHROPIC_CUSTOM_HEADERS"* ]] && echo 1 || echo 0)" +# 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" @@ -106,6 +108,8 @@ contains "attribution preserved alongside env" "$(cat "$settings")" '"attributio # 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"