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:
co-authored by
Claude Opus 5
parent
92055c4556
commit
2edafdaf0d
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user