fix(fleet): a microVM authenticates by subscription only — never with an API key

B4.4 had the microVM path share `forwarded_provider_env(auth)` with the
container path, on the reasoning that the two must not diverge. That was wrong
in the one direction that costs money: gw-04 has CLAWMATES_RUNTIME_AUTH unset,
so the container path forwards ANTHROPIC_API_KEY today — and a VM would have
received it. Claude Code ranks the API key ABOVE the subscription's OAuth token,
so the VM would have worked perfectly while billing per-token against a plan we
already pay for. No error, no symptom but the invoice.

`microvm_provider_env` is subscription-only BY CONSTRUCTION: it does not take
the auth mode as an argument and does not read CLAWMATES_RUNTIME_AUTH at all.
Taking the mode as a parameter would mean one unset variable on a new host
silently turns the API key back on. The container path is unchanged and still
honours the operator's mode — the divergence is now deliberate, with the reason
at the definition.

Two other fail-closed rules fall out of it:
  - A missing or blank subscription token REFUSES the launch rather than
    returning an empty environment. A VM with no credential does not error;
    `claude -p` hangs, which reads as a phase stuck at `running` with nothing in
    the logs. The refusal names the variable.
  - An unrecognised backend is refused rather than handed the Anthropic token.
    GLM and Kimi reach their own endpoints via ANTHROPIC_BASE_URL and that
    contract is not settled yet; guessing it would send a subscription
    credential to z.ai.

Measured on tank, and this is the end-to-end proof B4.4 could not give:
`claude -p` in the agent-claude image with the real subscription token replies
"OK". Injecting the token in a VM moves the failure from "Not logged in" to a
network error, so the credential channel is accepted by the CLI — the VM's
remaining problem is egress (#49), not auth.

447 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 12:19:55 -07:00
co-authored by Claude Opus 5
parent 92055c4556
commit 2edafdaf0d
+134 -13
View File
@@ -110,19 +110,84 @@ pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> {
/// The forwarded credentials that are actually SET on this server, as pairs. /// The forwarded credentials that are actually SET on this server, as pairs.
/// ///
/// The container path and the microVM path must agree on which credentials /// `forwarded_provider_keys` above stays the single list of *what* forwards; this
/// travel, or a mission behaves differently depending on where it landed — /// is the single reader of the environment, so the container and microVM paths
/// including the expensive way, where one path forwards `ANTHROPIC_API_KEY` and /// cannot disagree about how a value is read (blank handling in particular).
/// bills the API while the other uses the subscription. So both read this, and
/// `forwarded_provider_keys` above stays the single list.
/// ///
/// A key that is unset is simply absent: on the microVM path an empty-string /// A key that is unset is simply absent: an empty-string value would make
/// value would make `claude` believe it has a credential and fail /// `claude` believe it has a credential and fail authentication instead of
/// authentication instead of reporting that it has none. /// reporting that it has none.
pub fn forwarded_provider_env(auth: RuntimeAuth) -> Vec<(String, String)> { pub fn forwarded_provider_env(auth: RuntimeAuth) -> Vec<(String, String)> {
provider_env_from(auth, |k| std::env::var(k).ok()) provider_env_from(auth, |k| std::env::var(k).ok())
} }
/// Credentials for a microVM mission. **Subscription only, by construction.**
///
/// Deliberately NOT parameterised by [`runtime_auth_mode`], and that is the
/// whole point. The container path honours the operator's mode, and gw-04 has
/// `CLAWMATES_RUNTIME_AUTH` unset today, so it forwards `ANTHROPIC_API_KEY`. A
/// microVM must not: Claude Code ranks the API key **above** the subscription's
/// OAuth token, so a VM that received both would bill per-token against a plan
/// we already pay for, silently — `claude` still works, agents still run, and
/// the only symptom is the invoice. Taking the mode as an argument would mean a
/// single unset env var on a new host turns that back on.
///
/// A missing subscription token is an **error**, not an empty list. A VM launched
/// without a credential does not fail: `claude -p` hangs, which is a phase stuck
/// at `running` with nothing in the logs.
pub fn microvm_provider_env(backend: Option<&str>) -> Result<Vec<(String, String)>, String> {
microvm_provider_env_from(backend, |k| std::env::var(k).ok())
}
/// The credential a backend's CLI authenticates with inside a VM.
///
/// Unknown backends are refused rather than given the Anthropic token: sending a
/// subscription credential to whatever endpoint an unrecognised backend points
/// at is worse than not launching. GLM and Kimi reach their own endpoints via
/// `ANTHROPIC_BASE_URL` and need that contract settled (B4.6) before a VM can
/// carry their keys — guessing it here would send an Anthropic token to z.ai.
fn microvm_credential_for(backend: Option<&str>) -> Result<&'static str, String> {
match backend {
None | Some("") | Some("default") | Some("claude") => Ok("CLAUDE_CODE_OAUTH_TOKEN"),
Some(other) => Err(format!(
"backend {other:?} has no defined microVM credential contract yet — \
refusing to launch rather than forward an Anthropic subscription \
token to another provider's endpoint"
)),
}
}
fn microvm_provider_env_from(
backend: Option<&str>,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Vec<(String, String)>, String> {
let want = microvm_credential_for(backend)?;
let token = lookup(want)
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| {
format!(
"{want} is not set on this server, so a microVM mission would run \
`claude -p` with no credential — which hangs rather than failing. \
Set it, or run the mission on the container path."
)
})?;
let mut env = vec![(want.to_string(), token)];
// Non-Anthropic providers a mission's tools may need, forwarded when set.
// ANTHROPIC_API_KEY is absent from this list and must stay absent — see the
// doc comment above.
for k in ["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] {
if let Some(v) = lookup(k).filter(|v| !v.trim().is_empty()) {
env.push((k.to_string(), v));
}
}
debug_assert!(
!env.iter().any(|(k, _)| k == "ANTHROPIC_API_KEY"),
"the microVM path must never forward ANTHROPIC_API_KEY"
);
Ok(env)
}
/// The testable half of [`forwarded_provider_env`]. The lookup is a parameter /// The testable half of [`forwarded_provider_env`]. The lookup is a parameter
/// because a test cannot set process environment variables here — the workspace /// because a test cannot set process environment variables here — the workspace
/// denies `unsafe`, and `set_var` is racy across test threads regardless. /// denies `unsafe`, and `set_var` is racy across test threads regardless.
@@ -989,12 +1054,68 @@ mod tests {
/// means every mission silently bills the API while looking correct. There /// means every mission silently bills the API while looking correct. There
/// is no error to observe — only the invoice. If this test ever goes red, /// is no error to observe — only the invoice. If this test ever goes red,
/// the subscription path is off even though nothing appears broken. /// the subscription path is off even though nothing appears broken.
/// The container path and the microVM path must forward the SAME set. If /// The microVM path is subscription-only BY CONSTRUCTION — it does not read
/// they diverge, a mission behaves differently depending on where it landed /// `CLAWMATES_RUNTIME_AUTH` at all. If it did, the single unset variable on
/// — including the expensive way, where one path forwards the API key and /// gw-04 today would put an API key inside every VM, and Claude Code ranks
/// bills it while the other uses the subscription. /// the key above the subscription token: it would work, and bill per-token
/// against a plan already paid for, with no symptom but the invoice.
#[test] #[test]
fn both_execution_paths_forward_the_same_credentials() { fn a_microvm_never_receives_an_anthropic_api_key() {
// Everything set, including the key the container path would forward.
let env = microvm_provider_env_from(Some("claude"), |k| Some(format!("value-of-{k}")))
.expect("a set token should launch");
let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
assert!(
!keys.contains(&"ANTHROPIC_API_KEY"),
"the API key outranks the subscription token; forwarding it bills the \
API silently. Forwarded: {keys:?}"
);
assert!(
keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN"),
"the subscription credential must travel: {keys:?}"
);
}
/// No token is a refusal to launch, not an empty environment. A VM without a
/// credential does not error — `claude -p` hangs, which reads as a phase
/// stuck at `running` with nothing in the logs.
#[test]
fn a_microvm_without_a_subscription_token_refuses_to_launch() {
let r = microvm_provider_env_from(Some("claude"), |k| {
// The API key is present; the subscription token is not. This must
// NOT be treated as "we have a credential".
(k == "ANTHROPIC_API_KEY").then(|| "sk-ant-whatever".to_string())
});
let err = r.expect_err("no subscription token must refuse the launch");
assert!(err.contains("CLAUDE_CODE_OAUTH_TOKEN"), "{err}");
// A blank token is the same as no token.
assert!(microvm_provider_env_from(Some("claude"), |k| {
(k == "CLAUDE_CODE_OAUTH_TOKEN").then(|| " ".to_string())
})
.is_err());
}
/// A backend whose credential contract is not settled is refused, rather
/// than handed the Anthropic subscription token to send at its endpoint.
#[test]
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
for b in [Some("glm"), Some("kimi"), Some("something-new")] {
let r = microvm_provider_env_from(b, |_| Some("set".into()));
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
}
// The default image is the claude one, and does have a contract.
for b in [None, Some(""), Some("default"), Some("claude")] {
assert!(
microvm_provider_env_from(b, |_| Some("set".into())).is_ok(),
"backend {b:?}"
);
}
}
/// The container path still honours the operator's mode, and reads values
/// the same way. Only the microVM path is pinned.
#[test]
fn the_container_path_forwards_its_configured_mode() {
for auth in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] { for auth in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
let keys = forwarded_provider_keys(auth); let keys = forwarded_provider_keys(auth);
// Every key set in the environment, and nothing else. // Every key set in the environment, and nothing else.