Summary
Today coop forwards ANTHROPIC_API_KEY (via claude.api_key config or process env) into the guest, but users who authenticate Claude Code via the OAuth flow (claude /login) have no way to bring that session into the VM. They have to re-authenticate inside every guest, or switch to API-key billing they may not want.
Add a claude.oauth_token config field that resolves to CLAUDE_CODE_OAUTH_TOKEN in the guest, mirroring how claude.api_key resolves to ANTHROPIC_API_KEY.
Motivation
- Subscription users (Pro/Max) authenticate via OAuth, not API keys. Today they get no help from coop.
- The OAuth credential lives somewhere different on every host: macOS Keychain (
Claude Code-credentials service), Linux ~/.claude/.credentials.json, or the long-lived output of claude setup-token. The cmd: prefix already supported by claude.api_key is the right shape for resolving any of these.
~/.claude/.credentials.json is intentionally excluded from the staged config copy in src/backend.rs:1258 and should stay excluded — env-var forwarding keeps the secret off the guest disk.
Proposed approach
Config
Add oauth_token: Option<String> to ClaudeConfig in src/config.rs:447. Like api_key, it accepts either a literal value or a cmd: shell-out resolved at VM start.
[claude]
# Long-lived headless token from `claude setup-token`, stashed in Keychain
oauth_token = "cmd:security find-generic-password -s coop-claude-token -w"
Forwarding
Extend prepare_env_forwarding in src/backend.rs:856 with a parallel branch that calls resolve_cmd_value and sets CLAUDE_CODE_OAUTH_TOKEN in the EnvForward. SSH SendEnv carries it into the guest unchanged.
Precedence
If both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN reach the guest, the Claude CLI prefers the API key. Document this. Do not auto-suppress one when the other is set — let users decide which billing path they want.
Token sources and tradeoffs
Three credentials look interchangeable but aren't:
| Source |
Lifetime |
Refresh in guest |
Suitable |
claude setup-token output |
Long, headless-purposed |
N/A — long-lived |
Recommended |
macOS Keychain accessToken |
~hours |
Fails — guest has no writable credential store |
Short sessions only |
Linux ~/.claude/.credentials.json accessToken |
~hours |
Same as Keychain |
Short sessions only |
The recommended setup is to run claude setup-token once, stash the result wherever the user keeps secrets (Keychain, password manager, plain env), and reference it via cmd:. Pipe directly into the store so the literal token never lands in shell history, e.g. claude setup-token | security add-generic-password -s coop-claude-token -w.
Out of scope
- Copying
~/.claude/.credentials.json into the guest. Putting a refresh token on disk in a second location is a different security tradeoff and should be debated separately if proposed.
- Auto-detecting and extracting tokens from Keychain / credentials.json without user config. Implicit credential reads on every VM start are exactly the kind of magic that turns into a supply-chain attack vector — explicit
cmd: is the right friction.
- Changing
claude.api_key semantics or precedence.
Security considerations
The design is sound — it reuses the env-forward + cmd: pattern that claude.api_key already establishes. The risks are all in implementation details, not the shape:
Patterns to keep (and treat as canonical for future secret-forwarding work):
- Env-var forwarding over disk staging — guest images get snapshotted, copied, and backed up; secrets must not ride along.
cmd: indirection over literal TOML values — TOML gets dotfile-synced, Debug-printed, and grepped.
- Lazy resolution at VM-start, not config-parse — secret-manager prompts only fire when needed, and the resolved string lives in memory only for the duration of
prepare_env_forwarding. Do not memoize across instances.
- Recommending
claude setup-token over access tokens — refresh-token-derived access tokens cannot refresh inside the guest (no writable credential store), so they silently expire mid-session and push users toward worse workarounds (paste-into-config, store-on-disk).
- Keeping
~/.claude/.credentials.json excluded — putting the refresh token on guest disk in a second location is strictly worse than env-var forwarding.
- No precedence flip — let the Claude CLI's documented precedence apply, instead of coop suppressing one credential. Avoids surprise billing routes and surprise auth identities.
Pitfalls to avoid in implementation:
SendEnv vs SetEnv. SendEnv=FOO forwards from the SSH client's process env. If achieved by mutating coop's own process env, the secret leaks into anything else coop spawns. Use SetEnv=NAME=value on the ssh CLI, or scope the env to the child ssh process only — never std::env::set_var. Add a test that asserts the host process env is unchanged after prepare_env_forwarding.
- Guest
AcceptEnv allowlist. Add CLAUDE_CODE_OAUTH_TOKEN explicitly. Do not take the shortcut of AcceptEnv * — that turns the guest into a sink for arbitrary host env.
- Debug/log leakage.
ClaudeConfig derives Debug (src/config.rs:447); literal oauth_token values would be printed verbatim. Either custom Debug that redacts, or explicitly document the risk and push hard toward cmd:. Also: resolve_cmd_value includes command stderr in error messages (src/config.rs:63-71) — a misbehaving cmd: script that echoes the token to stderr would land it in error output. Document that resolver scripts must not emit the secret on stderr.
- No tracing of values. Mirror the existing
tracing::debug!("Resolving secret via command") — no field name, no length, no truncated value.
cmd: = sh -c, i.e. arbitrary code execution. Each new cmd:-resolved field grows the blast radius of a malicious or compromised config file. Acceptable today (the config is user-owned), but the moment coop ever auto-loads project-local coop.toml, cmd: resolution must be gated on an explicit user-level trust decision. Worth a tripwire comment near the resolver for future contributors.
- Claude CLI may persist the token guest-side. Verify empirically — if
claude writes ~/.claude/.credentials.json inside the guest after first auth, the host's no-disk policy is moot in practice. Either accept it (guest disk = trust boundary) and document it, or provide a stop-time scrub. Do not claim "never written to disk" without checking.
- Test fixtures must not look like real tokens. Use obvious fakes (
cmd:echo fake-oauth) so secret scanners and pre-commit detectors do not flag the repo and so regex scrapes cannot false-positive.
Acceptance criteria
Summary
Today coop forwards
ANTHROPIC_API_KEY(viaclaude.api_keyconfig or process env) into the guest, but users who authenticate Claude Code via the OAuth flow (claude /login) have no way to bring that session into the VM. They have to re-authenticate inside every guest, or switch to API-key billing they may not want.Add a
claude.oauth_tokenconfig field that resolves toCLAUDE_CODE_OAUTH_TOKENin the guest, mirroring howclaude.api_keyresolves toANTHROPIC_API_KEY.Motivation
Claude Code-credentialsservice), Linux~/.claude/.credentials.json, or the long-lived output ofclaude setup-token. Thecmd:prefix already supported byclaude.api_keyis the right shape for resolving any of these.~/.claude/.credentials.jsonis intentionally excluded from the staged config copy insrc/backend.rs:1258and should stay excluded — env-var forwarding keeps the secret off the guest disk.Proposed approach
Config
Add
oauth_token: Option<String>toClaudeConfiginsrc/config.rs:447. Likeapi_key, it accepts either a literal value or acmd:shell-out resolved at VM start.Forwarding
Extend
prepare_env_forwardinginsrc/backend.rs:856with a parallel branch that callsresolve_cmd_valueand setsCLAUDE_CODE_OAUTH_TOKENin theEnvForward. SSHSendEnvcarries it into the guest unchanged.Precedence
If both
ANTHROPIC_API_KEYandCLAUDE_CODE_OAUTH_TOKENreach the guest, the Claude CLI prefers the API key. Document this. Do not auto-suppress one when the other is set — let users decide which billing path they want.Token sources and tradeoffs
Three credentials look interchangeable but aren't:
claude setup-tokenoutputaccessToken~/.claude/.credentials.jsonaccessTokenThe recommended setup is to run
claude setup-tokenonce, stash the result wherever the user keeps secrets (Keychain, password manager, plain env), and reference it viacmd:. Pipe directly into the store so the literal token never lands in shell history, e.g.claude setup-token | security add-generic-password -s coop-claude-token -w.Out of scope
~/.claude/.credentials.jsoninto the guest. Putting a refresh token on disk in a second location is a different security tradeoff and should be debated separately if proposed.cmd:is the right friction.claude.api_keysemantics or precedence.Security considerations
The design is sound — it reuses the env-forward +
cmd:pattern thatclaude.api_keyalready establishes. The risks are all in implementation details, not the shape:Patterns to keep (and treat as canonical for future secret-forwarding work):
cmd:indirection over literal TOML values — TOML gets dotfile-synced, Debug-printed, and grepped.prepare_env_forwarding. Do not memoize across instances.claude setup-tokenover access tokens — refresh-token-derived access tokens cannot refresh inside the guest (no writable credential store), so they silently expire mid-session and push users toward worse workarounds (paste-into-config, store-on-disk).~/.claude/.credentials.jsonexcluded — putting the refresh token on guest disk in a second location is strictly worse than env-var forwarding.Pitfalls to avoid in implementation:
SendEnvvsSetEnv.SendEnv=FOOforwards from the SSH client's process env. If achieved by mutating coop's own process env, the secret leaks into anything else coop spawns. UseSetEnv=NAME=valueon the ssh CLI, or scope the env to the child ssh process only — neverstd::env::set_var. Add a test that asserts the host process env is unchanged afterprepare_env_forwarding.AcceptEnvallowlist. AddCLAUDE_CODE_OAUTH_TOKENexplicitly. Do not take the shortcut ofAcceptEnv *— that turns the guest into a sink for arbitrary host env.ClaudeConfigderivesDebug(src/config.rs:447); literaloauth_tokenvalues would be printed verbatim. Either custom Debug that redacts, or explicitly document the risk and push hard towardcmd:. Also:resolve_cmd_valueincludes command stderr in error messages (src/config.rs:63-71) — a misbehavingcmd:script that echoes the token to stderr would land it in error output. Document that resolver scripts must not emit the secret on stderr.tracing::debug!("Resolving secret via command")— no field name, no length, no truncated value.cmd:=sh -c, i.e. arbitrary code execution. Each newcmd:-resolved field grows the blast radius of a malicious or compromised config file. Acceptable today (the config is user-owned), but the moment coop ever auto-loads project-localcoop.toml,cmd:resolution must be gated on an explicit user-level trust decision. Worth a tripwire comment near the resolver for future contributors.claudewrites~/.claude/.credentials.jsoninside the guest after first auth, the host's no-disk policy is moot in practice. Either accept it (guest disk = trust boundary) and document it, or provide a stop-time scrub. Do not claim "never written to disk" without checking.cmd:echo fake-oauth) so secret scanners and pre-commit detectors do not flag the repo and so regex scrapes cannot false-positive.Acceptance criteria
claude.oauth_tokenconfig field, supports literal values andcmd:resolutionCLAUDE_CODE_OAUTH_TOKENforwarded into the guest without mutating coop's host process env (test asserts this)AcceptEnvallowlist explicitly includesCLAUDE_CODE_OAUTH_TOKEN(noAcceptEnv *)DebugonClaudeConfigapi_keycoverage (resolution, empty/failure paths), using obvious-fake token strings