feat(fleet): B4.4 — credentials reach the microVM guest as exec env, and a bad entry refuses the exec
`claude -p` in the VM failed with "Not logged in". The credential now travels on
the exec op: `env` on `vm_exec` → fcagent → the command's environment. An env var
rather than a file because the per-VM rootfs dies with the VM but an env var
never touches the guest disk at all.
**Every problem in an env entry fails the exec.** The tempting alternative —
skip the entry we cannot use and run anyway — produces a `claude -p` with no
credential, and that does not error, it HANGS. A phase stuck at `running` for
ten minutes with nothing in the logs is exactly what a missing token looked like
on the container path. Names are validated ('=' or NUL would define a different
variable than the one asked for via putenv semantics), values must be strings,
and errors name the key and never the value — an error string travels back over
the wire and into logs.
One list of which credentials travel: `forwarded_provider_env` reuses
`forwarded_provider_keys`, and the container path now reads it too. If the two
execution paths diverged, a mission would behave differently depending on where
it landed — including the expensive way, where one path forwards
ANTHROPIC_API_KEY and bills it while the other uses the subscription. A blank
value is omitted rather than forwarded empty, so `claude` reports having no
credential instead of failing authentication with one.
Verified on tank (`--vm-selftest` backend=claude, 13/13, create 1532 ms): an
injected var reaches the guest command over the real vsock wire, and an
unusable entry comes back ok:false with no rc.
FINDING — the CLI leg remains UNPROVEN, and deliberately so. The guest has no
network interface: `create` writes boot-source, drives, machine-config and vsock
and no `network-interfaces` key, and a booted guest has no routes, no
resolv.conf, no DNS and no TCP. So `claude -p` cannot reach the API whatever
credential it holds. Injecting the real token would have proven nothing, because
the failure would have been network and not auth. Filed as B4.6 (task #49) with
the TAP-vs-vsock-proxy trade-off; B4.5 is now blocked on it.
444 tests pass, clippy clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bcd1a0127d
commit
c3297b86cf
@@ -135,6 +135,56 @@ fn handle(req: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra environment for the command, on top of the image's own.
|
||||
///
|
||||
/// This is how credentials reach the agent CLI. An env var rather than a file
|
||||
/// because the per-VM rootfs is destroyed with the VM but an env var never
|
||||
/// touches the guest disk at all — it exists only in the process's environment
|
||||
/// for the length of one exec.
|
||||
///
|
||||
/// **Every problem here fails the exec.** The tempting alternative — skip the
|
||||
/// entry we could not use and run anyway — produces a `claude -p` with no
|
||||
/// credential, and that does not error: it hangs. A phase stuck at `running`
|
||||
/// for ten minutes with nothing in the logs is exactly what a missing token
|
||||
/// looked like on the container path, so a request we cannot honour in full is
|
||||
/// refused with a reason instead.
|
||||
///
|
||||
/// Errors name the key and never the value: the value is the secret, and an
|
||||
/// error string travels back over the wire and into logs.
|
||||
fn env_pairs(req: &Value) -> Result<Vec<(String, String)>, String> {
|
||||
let Some(env) = req.get("env") else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
// `null` means "nothing to add" — that is what a caller with no credentials
|
||||
// serialises. Anything else that is not an object is a caller bug.
|
||||
if env.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(map) = env.as_object() else {
|
||||
return Err("exec env must be an object of name → string".into());
|
||||
};
|
||||
let mut out = Vec::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let Some(val) = v.as_str() else {
|
||||
return Err(format!("exec env {k}: value must be a string"));
|
||||
};
|
||||
// `putenv` semantics: a name containing '=' would be parsed as part of
|
||||
// the value, silently defining a different variable than the one asked
|
||||
// for. A NUL truncates at the C boundary, for the same class of reason.
|
||||
if k.is_empty() {
|
||||
return Err("exec env has an empty variable name".into());
|
||||
}
|
||||
if k.contains('=') || k.contains('\0') {
|
||||
return Err(format!("exec env {k:?}: name may not contain '=' or NUL"));
|
||||
}
|
||||
if val.contains('\0') {
|
||||
return Err(format!("exec env {k}: value may not contain NUL"));
|
||||
}
|
||||
out.push((k.clone(), val.to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn op_exec(req: &Value) -> Value {
|
||||
let cmd = req.get("cmd").and_then(Value::as_str).unwrap_or_default();
|
||||
if cmd.is_empty() {
|
||||
@@ -142,6 +192,10 @@ fn op_exec(req: &Value) -> Value {
|
||||
}
|
||||
let cwd = req.get("cwd").and_then(Value::as_str).unwrap_or("/");
|
||||
let secs = req.get("timeout").and_then(Value::as_u64).unwrap_or(3600);
|
||||
let env = match env_pairs(req) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
|
||||
// The image's ENV was written to /etc/profile.d by the rootfs builder;
|
||||
// `sh -c` does not read it, so source it here — otherwise a CLI that relies
|
||||
@@ -158,6 +212,7 @@ fn op_exec(req: &Value) -> Value {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.arg("-c")
|
||||
.arg(&sourced)
|
||||
.envs(env)
|
||||
.current_dir(if Path::new(cwd).is_dir() { cwd } else { "/" })
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -292,6 +347,84 @@ fn op_get(req: &Value) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The credential has to actually reach the command. This is the whole
|
||||
/// point of the op, and the failure it prevents is silent: a `claude -p`
|
||||
/// with no token hangs rather than erroring.
|
||||
#[test]
|
||||
fn injected_env_reaches_the_command() {
|
||||
let r = op_exec(&json!({
|
||||
"op": "exec",
|
||||
"cmd": "printf %s \"$CLAUDE_CODE_OAUTH_TOKEN\"",
|
||||
"env": { "CLAUDE_CODE_OAUTH_TOKEN": "sk-test-value" },
|
||||
"timeout": 30,
|
||||
}));
|
||||
assert_eq!(r["rc"], json!(0));
|
||||
assert_eq!(r["stdout"], json!("sk-test-value"));
|
||||
}
|
||||
|
||||
/// And it must survive the profile.d sourcing that runs first — a
|
||||
/// credential set on the process and then clobbered by the shell would
|
||||
/// look identical to one that never arrived.
|
||||
#[test]
|
||||
fn injected_env_survives_the_image_env_file() {
|
||||
let r = op_exec(&json!({
|
||||
"op": "exec",
|
||||
"cmd": "printf %s \"$INJECTED_PROBE\"",
|
||||
"env": { "INJECTED_PROBE": "still-here" },
|
||||
"timeout": 30,
|
||||
}));
|
||||
assert_eq!(r["stdout"], json!("still-here"));
|
||||
}
|
||||
|
||||
/// No env is the ordinary case and must not be an error.
|
||||
#[test]
|
||||
fn absent_or_null_env_is_not_an_error() {
|
||||
for req in [
|
||||
json!({ "op": "exec", "cmd": "true", "timeout": 30 }),
|
||||
json!({ "op": "exec", "cmd": "true", "env": null, "timeout": 30 }),
|
||||
json!({ "op": "exec", "cmd": "true", "env": {}, "timeout": 30 }),
|
||||
] {
|
||||
assert_eq!(op_exec(&req)["rc"], json!(0), "{req}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An env entry we cannot honour fails the whole exec rather than being
|
||||
/// dropped. Running without the credential is the outcome this refuses:
|
||||
/// it does not error, it hangs, which is far harder to diagnose than a
|
||||
/// rejected request.
|
||||
#[test]
|
||||
fn an_unusable_env_entry_fails_the_exec_instead_of_being_skipped() {
|
||||
let cases = [
|
||||
json!({ "A=B": "x" }),
|
||||
json!({ "": "x" }),
|
||||
json!({ "TOKEN": 42 }),
|
||||
json!({ "TOKEN": null }),
|
||||
];
|
||||
for env in cases {
|
||||
let r = op_exec(&json!({
|
||||
"op": "exec", "cmd": "true", "env": env.clone(), "timeout": 30,
|
||||
}));
|
||||
assert_eq!(r["ok"], json!(false), "env {env} should be refused");
|
||||
assert!(r["rc"].is_null(), "nothing ran, so there is no rc: {r}");
|
||||
}
|
||||
// A non-object env is a caller bug, not an empty map.
|
||||
let r = op_exec(&json!({ "op": "exec", "cmd": "true", "env": "TOKEN=x" }));
|
||||
assert_eq!(r["ok"], json!(false));
|
||||
}
|
||||
|
||||
/// An error about a credential must not quote the credential: it travels
|
||||
/// back over the wire and into the server's logs.
|
||||
#[test]
|
||||
fn an_env_error_never_echoes_the_value() {
|
||||
let r = op_exec(&json!({
|
||||
"op": "exec", "cmd": "true", "timeout": 30,
|
||||
"env": { "A=B": "super-secret-token" },
|
||||
}));
|
||||
let err = r["error"].as_str().unwrap_or_default();
|
||||
assert!(!err.contains("super-secret-token"), "leaked the value: {err}");
|
||||
assert!(err.contains("A=B"), "should name the key: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_op_is_reported_not_ignored() {
|
||||
let r = handle(&json!({ "op": "teleport" }));
|
||||
|
||||
Reference in New Issue
Block a user