fix(missions): forward the subscription token into mission containers
ci / gates (push) Failing after 9s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Switching agents to claude_cli left missions hanging: the per-mission
container had claude_cli configured but no credential, so `claude -p`
waited forever. A phase sat at `running` for ten minutes with nothing in
the logs — no error, because there is nothing to error on.

The original subscription design assumed a persisted `claude /login`
under a bind-mounted $HOME. That holds for the shared runtime and NOT for
a mission container, which gets its own data dir and therefore no login.
So subscription mode now forwards CLAUDE_CODE_OAUTH_TOKEN.

The two Anthropic credentials remain mutually exclusive, and there is now
a test asserting it in both directions: Claude Code ranks ANTHROPIC_API_KEY
above the OAuth token, so shipping both bills the API while the deployment
believes it is on the subscription — visible only on the invoice.

Deployment: CLAWMATES_RUNTIME_AUTH=subscription and CLAUDE_CODE_OAUTH_TOKEN
added to compose + .env on gw-04.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-03 17:34:13 -07:00
co-authored by Claude Opus 5
parent ac47dcbe94
commit 5232175c88
+37 -2
View File
@@ -83,10 +83,18 @@ impl RuntimeAuth {
/// ///
/// The other three are unrelated providers with no subscription equivalent, so /// The other three are unrelated providers with no subscription equivalent, so
/// they forward in both modes. /// they forward in both modes.
///
/// In subscription mode `CLAUDE_CODE_OAUTH_TOKEN` forwards instead. The
/// original design assumed a persisted `claude /login` under a bind-mounted
/// `$HOME`, but a *mission* container gets its own data dir and therefore no
/// login — so the token has to travel. Missing it is not a loud failure:
/// `claude -p` simply hangs with no credential, which is what a phase stuck
/// at `running` for ten minutes looked like when this was first switched on.
pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> { pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> {
let mut keys = vec!["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"]; let mut keys = vec!["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"];
if auth == RuntimeAuth::ApiKey { match auth {
keys.push("ANTHROPIC_API_KEY"); RuntimeAuth::ApiKey => keys.push("ANTHROPIC_API_KEY"),
RuntimeAuth::Subscription => keys.push("CLAUDE_CODE_OAUTH_TOKEN"),
} }
keys keys
} }
@@ -805,6 +813,29 @@ mod tests {
for k in ["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] { for k in ["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] {
assert!(keys.contains(&k), "{k} should still be forwarded"); assert!(keys.contains(&k), "{k} should still be forwarded");
} }
// And the subscription credential MUST travel. A mission container
// has its own data dir, so unlike the shared runtime it has no
// persisted `claude /login` to fall back on. Without this the CLI
// has no credential and simply hangs — a phase stuck at `running`
// with nothing in the logs, which is exactly how this was found.
assert!(
keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN"),
"subscription mode must forward the token; without it `claude -p` \
hangs with no credential. Forwarded: {keys:?}"
);
}
/// The two credentials must never travel together: Claude Code would pick
/// the API key and bill it while the deployment believes it is on the
/// subscription.
#[test]
fn the_two_anthropic_credentials_are_mutually_exclusive() {
for mode in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
let keys = forwarded_provider_keys(mode);
let both = keys.contains(&"ANTHROPIC_API_KEY")
&& keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
assert!(!both, "{mode:?} forwards both credentials: {keys:?}");
}
} }
/// Default behaviour is unchanged, so a deployment that never opts in keeps /// Default behaviour is unchanged, so a deployment that never opts in keeps
@@ -820,6 +851,10 @@ mod tests {
] { ] {
assert!(keys.contains(&k), "{k} should be forwarded in api_key mode"); assert!(keys.contains(&k), "{k} should be forwarded in api_key mode");
} }
assert!(
!keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN"),
"api_key mode must not also ship the subscription token"
);
} }
/// An unset or misspelled value must fall back to the *existing* behaviour. /// An unset or misspelled value must fall back to the *existing* behaviour.