Skip to content

Forward Claude Code OAuth credentials from host into the guest #62

Description

@hbrodin

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:

  1. 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.
  2. 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.
  3. 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.
  4. No tracing of values. Mirror the existing tracing::debug!("Resolving secret via command") — no field name, no length, no truncated value.
  5. 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.
  6. 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.
  7. 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

  • claude.oauth_token config field, supports literal values and cmd: resolution
  • CLAUDE_CODE_OAUTH_TOKEN forwarded into the guest without mutating coop's host process env (test asserts this)
  • Guest sshd AcceptEnv allowlist explicitly includes CLAUDE_CODE_OAUTH_TOKEN (no AcceptEnv *)
  • Resolved token never appears in tracing/log output at any level, and is not printed by Debug on ClaudeConfig
  • Documented in README / config reference, including the access-token-vs-setup-token caveat and the pipe-into-secret-store recommendation
  • Documented behavior of the Claude CLI inside the guest re: on-disk persistence of the OAuth token (verified empirically)
  • Unit tests mirroring the api_key coverage (resolution, empty/failure paths), using obvious-fake token strings

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions