Skip to content

Per-workspace AI gateway config (ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS)#403

Open
dhilgaertner wants to merge 7 commits into
mainfrom
feature/crow-402-workspace-ai-gateway
Open

Per-workspace AI gateway config (ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS)#403
dhilgaertner wants to merge 7 commits into
mainfrom
feature/crow-402-workspace-ai-gateway

Conversation

@dhilgaertner
Copy link
Copy Markdown
Contributor

Closes #402

Routes Claude Code through a per-workspace AI gateway (ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS) instead of a global ~/.zshrc export, so different workspaces can use different gateways/keys — or the vanilla Anthropic API — and a non-Radius workspace never inherits the Corveil gateway.

Design decisions

  • Q1 — Manager gateway: (c) its own managerGateway. The Manager sits at devRoot and isn't bound to one workspace; a top-level managerGateway mirrors the existing managerAutoPermissionMode pattern. Per-workspace gateway applies to non-Manager sessions only.
  • Q2 — Re-launch: settings.local.json env block (not tmux -e). setup.sh already writes .claude/settings.local.json (attribution) and the app already merges into it; Claude Code reads its env key on every run, so this covers initial launch and manual claude re-runs with no tmux plumbing and no secret in ps/argv. A launch-line prefix (or unset … && when no gateway) handles the initial-launch override and the no-leak guard. This supersedes the ticket's tmux -e option, which would put the token in process args and lose to ~/.zshrc sourcing anyway.
  • Q3 — Validation: parse-time rejection. WorkspaceGateway decode throws on a half-filled block (baseURL xor customHeaders); the Settings UI blocks save on the same condition so a rejectable block is never written.

Secret handling

Default is op:// reference + plaintext-with-warning; Keychain deferred behind a clean GatewayResolver seam (you're a 1Password shop, and op:// already keeps nothing-secret-at-rest). To flip:

  • op:// (recommended) — header value like op://Vault/Item/field, resolved at launch via op read (app-side and in setup.sh). Nothing secret in config.json.
  • plaintext — any non-op:// value; stored in config.json (0600) with a red warning in Settings.
  • A failed op lookup drops that header (redacted log) and keeps the baseURL, so the gateway returns a loud 401 rather than silently falling back to vanilla. Resolved secrets are never logged.

What changed (commit per phase)

  1. WorkspaceGateway model + WorkspaceInfo.gateway + AppConfig.managerGateway, validating decode, tests.
  2. GatewayResolver — resolve op://, serialize Name: Value headers, tests.
  3. setup.sh — resolve gateway via jq/op read, merge env into settings.local.json, launch-line prefix / unset; setup_gateway_test.sh.
  4. App-side — HookConfigGenerator.writeGatewayEnv, ClaudeLaunchArgs.gatewayEnvPrefix, launchClaude (work/review/job) + Manager wiring.
  5. Settings UI — per-workspace "AI Gateway" + "Manager AI Gateway" editors.
  6. Docs — docs/configuration.md AI Gateway section.

Tests

  • swift test green: CrowCore (212), CrowPersistence (28), root app target (145).
  • bash skills/crow-workspace/setup_gateway_test.sh — 15/15 (op:// resolution via a fake op, settings merge, both prefix forms).
  • make build clean.

Manual verification (for the reviewer / @dustinhilgaertner to run)

  1. Set the RadiusMethod workspace's gateway (Base URL https://corveil.io, header x-citadel-api-key: op://…) in Settings → Workspaces.
  2. Comment out the ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS exports in ~/.zshrc.
  3. Launch a RadiusMethod workspace session → confirm it reaches Corveil; launch a no-gateway workspace → confirm vanilla Anthropic.
  4. Precedence check (one open item): with a dummy ANTHROPIC_BASE_URL still in ~/.zshrc and a different one in the worktree's settings.local.json env, confirm which wins. The launch-line prefix makes the override correct regardless, but this confirms whether settings env alone suffices.

Coordination

crow#390 (jobs auto-permission mode) is already merged/closed — this is independent, no rebase. Job sessions pick up their workspace's gateway through the same launchClaude path.

Copy link
Copy Markdown
Collaborator

@dgershman dgershman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code & Security Review

Solid, well-tested implementation overall — model / resolver / launch-prefix / merge writer / setup.sh helpers are cleanly separated, parse-time validation rejects half-filled gateways, and op:// resolution failures fail loud (gateway rejects) rather than silently falling back to vanilla. Round-trip and bash tests are thorough.

Two Yellow findings around the no-leak guarantee and at-rest secret protection that I'd like addressed in this round.

Yellow Findings

Y1 — Multi-header launch line leaves a ~/.zshrc leak path open. ClaudeLaunchArgs.gatewayEnvPrefix (Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift:59-68) and gateway_launch_prefix in setup.sh:148-157 both intentionally omit ANTHROPIC_CUSTOM_HEADERS from the launch line when there is more than one header (to avoid embedding a literal newline that would submit the command early). In that branch the prefix becomes just ANTHROPIC_BASE_URL='…' claude …, which is a command-prefix assignment — it sets ANTHROPIC_BASE_URL for the launched process but does not clear an ANTHROPIC_CUSTOM_HEADERS that the shell inherited from ~/.zshrc. Claude Code then sees the gateway's baseURL paired with the user's stale global headers (or no headers, depending on precedence with settings.local.json env). The PR description's "load-bearing no-leak guard" claim doesn't hold in the multi-header case.

Why: the PR explicitly motivates the launch-line prefix as "the load-bearing no-leak guard"; this is the one branch where it isn't.

How to apply: in the multi-header branch, prepend unset ANTHROPIC_CUSTOM_HEADERS && (or wrap the whole launch in env -u ANTHROPIC_CUSTOM_HEADERS …) before the ANTHROPIC_BASE_URL=… assignment, so the inherited shell var is removed before claude starts. Cover it with a test that asserts both unset ANTHROPIC_CUSTOM_HEADERS and ANTHROPIC_BASE_URL=… appear in the multi-header prefix.

Y2 — Resolved secrets persisted to settings.local.json with default file permissions. The PR description / Settings UI describe op:// as the "nothing-secret-at-rest" mode, but the resolved secret value is written into .claude/settings.local.json's env block on every launch:

  • HookConfigGenerator.writeGatewayEnv (Sources/Crow/App/HookConfigGenerator.swift:109-147) writes via data.write(to:) without setting permissions.
  • setup.sh:write_settings_local (skills/crow-workspace/setup.sh:431-502) writes via printf > "$settings_path" without chmod 600.

ConfigStore.saveConfig already sets 0600 on config.json for the same reason. settings.local.json now carries the same sensitivity (the post-op read plaintext bearer token) but inherits the umask default (typically 0644).

Why: the design intent is that op:// keeps secrets off disk; in practice the resolved value lands on disk in a file with weaker protection than config.json.

How to apply: when the merged settings dict contains env.ANTHROPIC_CUSTOM_HEADERS (or any non-empty env block), chmod 0600 the file after writing — both in HookConfigGenerator.writeGatewayEnv (use FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: settingsPath)) and in setup.sh:write_settings_local (chmod 600 "$settings_path"). Additionally, soften the docs / Settings UI copy: op:// means "nothing secret in config.json," not "nothing secret on disk" — the resolved value is cached in settings.local.json for the worktree's lifetime.

Green / Nits (consider, not blocking)

  • G1 — The "precedence open item" in the PR description is real. With ANTHROPIC_BASE_URL in ~/.zshrc and a different one in settings.local.json env, the test suite doesn't pin down which wins. Worth adding a Claude-Code-side smoke test (or at minimum a doc note in docs/configuration.md#ai-gateway) once you verify it manually, so the next person doesn't have to re-derive it.
  • G2 — is_remote_control_enabled / is_attribution_trailers_enabled use tr -d '\n' | grep -qE instead of jq (skills/crow-workspace/setup.sh:76-95). Pre-existing, not introduced here, but worth a follow-up to converge on the jq-based path used by resolve_gateway_env.
  • G3 — op read 15s timeout (Packages/CrowCore/Sources/CrowCore/GatewayResolver.swift:94-104) is reasonable for biometric prompts; could be made configurable later if Touch ID prompts feel sluggish.

Security Review

Strengths:

  • posix_quote in setup.sh correctly handles single-quote escaping; matches ClaudeLaunchArgs.shellQuote.
  • Resolved secret values are never logged (NSLog messages drop the header value, only naming the header key).
  • Failed op read drops the header rather than falling back silently — gateway 401 surfaces the misconfig.
  • gateway_launch_prefix uses single-quoted assignments, so a hostile baseURL value can't break out of quoting.
  • Parse-time rejection of half-filled gateways prevents the "baseURL without auth" footgun.
  • op read invocation uses $value quoted, so an op://… string with shell metachars can't inject.

Concerns:

  • Y1 and Y2 above.

Code Quality

  • Tests cover the model, persistence, resolver (incl. failure paths), the launch prefix builder, the bash helpers (15/15 passing locally), and UI form validation. Good.
  • Backward compat is correct — every new field uses decodeIfPresent with a default; legacy configs decode unchanged.
  • WorkspaceGateway.parseHeaderLines / headerLines form a clean round-trip for the editor.
  • GatewayResolver is dependency-injected (resolveSecret:) so the resolver itself is testable without op.
  • Single source for the workspace-name-from-worktree-path math (SessionService.workspaceName), good for testability.
  • Settings UI surfaces validation inline and blocks save on a half-filled block — matches the parse-time invariant.

Summary Table

Color Meaning Verdict effect
Red Must fix Request changes
Yellow Should fix Request changes
Green Consider Approve allowed

Recommendation: Request Changes — driven by 0 Red, 2 Yellow, 3 Green findings. The two Yellows are scoped and should be quick to land in the same round trip.


🐦‍⬛ Reviewed by Crow via Claude Code

dhilgaertner added a commit that referenced this pull request Jun 1, 2026
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
@dhilgaertner
Copy link
Copy Markdown
Contributor Author

Thanks for the review — both Yellows addressed in 0f0a7e3.

Y1 — multi-header leak path (fixed). The multi-header branch now unsets the inherited header before the baseURL assignment, in both places:

  • ClaudeLaunchArgs.gatewayEnvPrefixunset ANTHROPIC_CUSTOM_HEADERS && ANTHROPIC_BASE_URL='…'
  • setup.sh gateway_launch_prefix → same shape.

So the gateway's baseURL is never paired with a stale ~/.zshrc header value; the real (multi-line) header value still comes from settings.local.json. Tests assert both unset ANTHROPIC_CUSTOM_HEADERS and ANTHROPIC_BASE_URL=… appear (and no embedded newline) — gatewayEnvPrefixUnsetsInheritedHeadersForMultiLine (Swift) and the bash test's multi-header case.

Y2 — 0600 on settings.local.json (fixed). Both writers now restrict the file to owner-only after writing, matching ConfigStore's 0600 on config.json:

  • HookConfigGenerator.writeGatewayEnvsetAttributes([.posixPermissions: 0o600], …)
  • setup.sh write_settings_localchmod 600.

Added a stat-based 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 for its lifetime.

G1 — precedence (doc note added). Added a precedence note to docs/configuration.md#ai-gateway: the launch-line assignment is what reliably overrides a shell export; the intended end state is to delete the global ~/.zshrc exports so config.json is the single source of truth. I'll pin down the settings-env-vs-shell precedence in the manual smoke test and fold the result into that note.

G2 / G3 — acknowledged, deferred. G2 (converging is_remote_control_enabled / is_attribution_trailers_enabled onto the jq path) is pre-existing and out of scope for this PR — happy to do it as a follow-up. G3 (configurable op read timeout) noted for if Touch ID prompts feel sluggish.

All suites green: CrowCore 212, root 145, setup_gateway_test.sh 17/17, make build clean.

@dhilgaertner dhilgaertner requested a review from dgershman June 1, 2026 16:44
Copy link
Copy Markdown
Collaborator

@dgershman dgershman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code & Security Review

Re-review after the fix commit (0f0a7e3). Both Yellow findings from the prior review and G1 are closed cleanly; no new Red/Yellow findings emerged.

Verification of prior findings

  • Y1 — multi-header ~/.zshrc leak path is closed. ClaudeLaunchArgs.gatewayEnvPrefix (Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift:60-69) and gateway_launch_prefix (skills/crow-workspace/setup.sh:150-161) both now emit unset ANTHROPIC_CUSTOM_HEADERS && ANTHROPIC_BASE_URL='…' in the multi-header branch. gatewayEnvPrefixUnsetsInheritedHeadersForMultiLine (GatewayResolverTests.swift:77-92) and the bash test ("multi-header prefix unsets inherited headers", "multi-header prefix omits headers assignment", "launch prefix has no embedded newline") pin the contract.
  • Y2 — resolved secrets at-rest now owner-only. HookConfigGenerator.writeGatewayEnv (Sources/Crow/App/HookConfigGenerator.swift:143-146) calls FileManager.setAttributes([.posixPermissions: 0o600], …) after every write. setup.sh:write_settings_local (skills/crow-workspace/setup.sh:487-489) chmod 600s the file. The bash test asserts the mode is 600.
  • G1 — precedence note is in docs/configuration.md (note at lines 100-102): the launch-line assignment is the reliable override, and the end state is to delete the global ~/.zshrc exports. Docs/UI copy also softened — op:// is now described as "kept out of config.json," not "no secret on disk."

Tests

  • swift test --filter GatewayResolverTests — 8/8 green locally.
  • bash skills/crow-workspace/setup_gateway_test.sh — 17/17 green locally (the new 0600 assertion + the two new multi-header assertions land).

Security Review

Strengths (re-confirmed):

  • posix_quote / shellQuote POSIX-escape correctly; hostile baseURL values can't break quoting.
  • Resolved secret values are still never logged (NSLog drops the value, only naming the header key).
  • Failed op read drops the header rather than falling back silently — the gateway returns a loud 401.
  • Parse-time rejection of half-filled gateways stops "baseURL without auth" at decode.
  • Both writeGatewayEnv and write_settings_local now match ConfigStore.saveConfig's 0600 stance on the at-rest secret.

Concerns: None blocking.

Code Quality

  • Behavior change is minimal and well-targeted; doc comments on both prefix builders accurately describe all three branches (nil / single-header / multi-header).
  • try? on setAttributes in writeGatewayEnv mirrors existing best-effort logging patterns elsewhere.
  • Backward compat unchanged — every new field still uses decodeIfPresent with a default.

Green / Nits (consider, not blocking)

  • G2 (carried over) — is_remote_control_enabled / is_attribution_trailers_enabled still use tr | grep instead of jq. Pre-existing; worth a follow-up to converge on the jq-based path used by resolve_gateway_env.
  • G3 (carried over) — op read 15s timeout is reasonable; could be made configurable later if Touch ID prompts ever feel sluggish.
  • G4 — brief perm race on settings.local.json. In launchClaude, writeHookConfig runs before writeGatewayEnv; its atomic write recreates the file with default umask (~0644) before writeGatewayEnv re-applies 0600. The window is microseconds on a single-user macOS app, end state is 0600, and no exploit is plausible — but writeHookConfig could also set 0600 unconditionally to close it.
  • G5 — AutomationSettingsView.commitManagerGateway leaves the previous valid gateway in place when the form goes half-filled. The inline red error tells the user, and the form has no Save button to commit a bad state. UX nit, not security.

Summary Table

Color Meaning Verdict effect
Red Must fix Request changes
Yellow Should fix Request changes
Green Consider Approve allowed

Recommendation: Approve — driven by 0 Red, 0 Yellow, 5 Green findings. The two prior Yellows (Y1 launch-line leak, Y2 0600 at-rest) and G1 (precedence note) all landed cleanly with test coverage.


🐦‍⬛ Reviewed by Crow via Claude Code

dhilgaertner added a commit that referenced this pull request Jun 2, 2026
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
@dhilgaertner dhilgaertner force-pushed the feature/crow-402-workspace-ai-gateway branch from 0f0a7e3 to 1afbb20 Compare June 2, 2026 16:58
@dhilgaertner
Copy link
Copy Markdown
Contributor Author

Rebased onto main to clear the merge conflict — main had absorbed #197 (agent abstraction + OpenAI Codex MVP), which moved several files this PR depended on. Net behavior is unchanged; the gateway logic was re-homed to the new structure:

  • ClaudeLaunchArgs moved CrowCoreCrowClaude. gatewayEnvPrefix moved with it (and now import CrowCore for GatewayResolver.Resolved).
  • HookConfigGenerator (app target) was deleted in favour of CrowClaude/ClaudeHookConfigWriter. writeGatewayEnv moved there (now public, keeps the 0600 hardening).
  • Launch path is now the CodingAgent abstraction. SessionService.launchClaudelaunchAgent, which builds the command via agent.autoLaunchCommand(...). Gateway resolution + the settings.local.json env write are gated to agent.kind == .claudeCode (ANTHROPIC_* is Claude-specific; Codex sessions are untouched), and the launch-line prefix is prepended at the send site.
  • gatewayEnvPrefix now emits export … && instead of bare VAR=val …. The agent bakes an OTEL export … && claude prefix into the command, and a bare command-prefix assignment would bind only to that export builtin rather than claude. export … && composes correctly in front of it. (setup.sh keeps the bare form — its launch line has nothing between the prefix and claude.)
  • The gatewayEnvPrefix unit tests moved from CrowCoreTests to CrowClaudeTests (they can't import CrowClaude from CrowCore), updated for the export form.

Verification on the rebased branch: make build clean; CrowCore 210, CrowClaude 40, CrowPersistence 28, root 145, setup_gateway_test.sh 17/17 — all green.

dhilgaertner added a commit that referenced this pull request Jun 3, 2026
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
@dhilgaertner dhilgaertner force-pushed the feature/crow-402-workspace-ai-gateway branch from 1afbb20 to e96c6a5 Compare June 3, 2026 04:15
dhilgaertner and others added 7 commits June 3, 2026 22:16
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
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 <noreply@anthropic.com>
Crow-Session: E3DFAF9C-38CB-4D95-979D-9CC48F4EC3C9
@dhilgaertner dhilgaertner force-pushed the feature/crow-402-workspace-ai-gateway branch from e96c6a5 to d8bc867 Compare June 4, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace-level AI gateway config: per-workspace ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS for Claude Code launches

2 participants