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
@@ -328,17 +328,24 @@ pub async fn inject(vms: &Vms, vm_id: &str, dest: &str, tar_b64: &str) -> Result
|
||||
}
|
||||
|
||||
/// Run a command in the guest and return its exit code and output.
|
||||
/// `env` is passed straight to the guest, which validates it and refuses the
|
||||
/// exec if any entry is unusable. It is NOT logged here or anywhere on the way:
|
||||
/// this is the channel credentials travel on.
|
||||
pub async fn exec(
|
||||
vms: &Vms,
|
||||
vm_id: &str,
|
||||
cmd: &str,
|
||||
cwd: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
env: Option<&Value>,
|
||||
) -> Result<Value, String> {
|
||||
let uds = uds_of(vms, vm_id).await?;
|
||||
rpc(
|
||||
&uds,
|
||||
&json!({ "op": "exec", "cmd": cmd, "cwd": cwd, "timeout": timeout_secs }),
|
||||
&json!({
|
||||
"op": "exec", "cmd": cmd, "cwd": cwd,
|
||||
"timeout": timeout_secs, "env": env,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -431,7 +438,15 @@ pub async fn handle_op(op: &str, v: &Value, vms: &Vms) -> (bool, String) {
|
||||
"vm_inject" => inject(vms, &vm_id, &s("dest"), &s("tar_b64")).await,
|
||||
"vm_exec" => {
|
||||
let cwd = v.get("cwd").and_then(Value::as_str);
|
||||
exec(vms, &vm_id, &s("cmd"), cwd, u("timeout", 3600)).await
|
||||
exec(
|
||||
vms,
|
||||
&vm_id,
|
||||
&s("cmd"),
|
||||
cwd,
|
||||
u("timeout", 3600),
|
||||
v.get("env"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"vm_collect" => collect(vms, &vm_id, &s("path")).await,
|
||||
"vm_destroy" => destroy(vms, &vm_id).await,
|
||||
@@ -535,7 +550,7 @@ pub async fn selftest() -> bool {
|
||||
|
||||
// The guest must SEE what we injected — an inject that reports ok while
|
||||
// landing nothing is the failure shape this codebase keeps paying for.
|
||||
let r = exec(&vms, id, "cat /work/marker.txt", None, 30).await;
|
||||
let r = exec(&vms, id, "cat /work/marker.txt", None, 30, None).await;
|
||||
let saw = r
|
||||
.as_ref()
|
||||
.map(|v| v["stdout"].as_str().unwrap_or_default().contains("INJECTED-OK"))
|
||||
@@ -544,15 +559,57 @@ pub async fn selftest() -> bool {
|
||||
|
||||
// A failing command must come back as rc != 0, not as a transport error:
|
||||
// the caller needs to tell "the command failed" from "we could not run it".
|
||||
let r = exec(&vms, id, "exit 3", None, 30).await;
|
||||
let r = exec(&vms, id, "exit 3", None, 30, None).await;
|
||||
check(
|
||||
r.as_ref().map(|v| v["rc"] == json!(3)).unwrap_or(false),
|
||||
"a failing command reports rc=3 rather than an error",
|
||||
format!("{r:?}"),
|
||||
);
|
||||
|
||||
// Credentials reach the agent CLI as exec env, and this is the only place
|
||||
// that is proven over the real vsock wire rather than in a unit test: the
|
||||
// failure it guards against is a `claude -p` with no token, which does not
|
||||
// error — it hangs.
|
||||
let r = exec(
|
||||
&vms,
|
||||
id,
|
||||
"printf %s \"$CLAWMATES_ENV_PROBE\"",
|
||||
None,
|
||||
30,
|
||||
Some(&json!({ "CLAWMATES_ENV_PROBE": "env-injection-ok" })),
|
||||
)
|
||||
.await;
|
||||
check(
|
||||
r.as_ref()
|
||||
.map(|v| v["stdout"] == json!("env-injection-ok"))
|
||||
.unwrap_or(false),
|
||||
"injected env reaches the guest command",
|
||||
format!("{r:?}"),
|
||||
);
|
||||
|
||||
// And an entry the guest cannot honour must fail the exec rather than run
|
||||
// the command without it.
|
||||
let r = exec(
|
||||
&vms,
|
||||
id,
|
||||
"true",
|
||||
None,
|
||||
30,
|
||||
Some(&json!({ "BAD=NAME": "x" })),
|
||||
)
|
||||
.await;
|
||||
let refused = r
|
||||
.as_ref()
|
||||
.map(|v| v["ok"] == json!(false) && v["rc"].is_null())
|
||||
.unwrap_or(false);
|
||||
check(
|
||||
refused,
|
||||
"an unusable env entry refuses the exec instead of dropping it",
|
||||
format!("{r:?}"),
|
||||
);
|
||||
|
||||
// Work produced in the guest must come back out.
|
||||
let _ = exec(&vms, id, "echo PRODUCED-OK > /work/out.txt", None, 30).await;
|
||||
let _ = exec(&vms, id, "echo PRODUCED-OK > /work/out.txt", None, 30, None).await;
|
||||
let r = collect(&vms, id, "/work").await;
|
||||
let round_tripped = r
|
||||
.as_ref()
|
||||
@@ -580,7 +637,7 @@ pub async fn selftest() -> bool {
|
||||
// and it is exec'd inside the guest rather than inferred from the image name.
|
||||
match required_cli(backend.as_deref()) {
|
||||
Some((cli, probe)) => {
|
||||
let r = exec(&vms, id, probe, None, 60).await;
|
||||
let r = exec(&vms, id, probe, None, 60, None).await;
|
||||
let (rc, out) = match r.as_ref() {
|
||||
Ok(v) => (
|
||||
v["rc"].as_i64(),
|
||||
@@ -599,7 +656,7 @@ pub async fn selftest() -> bool {
|
||||
// git is what delivery is built on: the host captures a phase by
|
||||
// diffing the collected tree, so an image without git delivers
|
||||
// nothing no matter which CLI it has.
|
||||
let r = exec(&vms, id, "git --version", None, 30).await;
|
||||
let r = exec(&vms, id, "git --version", None, 30, None).await;
|
||||
check(
|
||||
r.as_ref().map(|v| v["rc"] == json!(0)).unwrap_or(false),
|
||||
"the guest provides git",
|
||||
@@ -632,7 +689,7 @@ pub async fn selftest() -> bool {
|
||||
|
||||
// And the VM must not be usable afterwards — a destroy that leaves a live
|
||||
// guest answering is worse than one that errors.
|
||||
let r = exec(&vms, id, "echo still-here", None, 5).await;
|
||||
let r = exec(&vms, id, "echo still-here", None, 5, None).await;
|
||||
check(
|
||||
r.is_err(),
|
||||
"a destroyed VM can no longer be exec'd",
|
||||
|
||||
Reference in New Issue
Block a user