From eb1137ed68fde56cdc00d76605573d0c4f78da17 Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Wed, 23 Sep 2026 14:41:30 -0500 Subject: [PATCH] fix(microvm): the relay URL must survive the login profile; deploy.sh must not ship a target symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The first relayed kimi mission (01a0cfc3) bound the node relay, yet the guest dialled api.kimi.com through egress with the proxy token: the turn runs in a login shell that sources /etc/profile.d/00-image-env.sh (the image's ENV), which re-exports the glm/kimi images' baked ANTHROPIC_BASE_URL over ours. The turn command now re-exports the base URL after the profile. No key leaked — the guest had only the token — but relayed kimi/glm missions could not run. - deploy.sh excluded `target/`, which matches only a directory; a workstation whose target is a symlink copied it to the build host, breaking its builds. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/cm-api/src/microvm_executor.rs | 33 ++++++++++++++++++++++++++- scripts/deploy.sh | 6 ++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/cm-api/src/microvm_executor.rs b/crates/cm-api/src/microvm_executor.rs index 7a3d978..86bc55e 100644 --- a/crates/cm-api/src/microvm_executor.rs +++ b/crates/cm-api/src/microvm_executor.rs @@ -405,6 +405,23 @@ fn agent_command(prompt: &str, settings: Option<&str>) -> String { ) } +/// Re-assert the base URL inside the command, after the login shell's profile. +/// +/// fcagent passes the turn's env into the process, but the command runs in a +/// login shell that then sources `/etc/profile.d/00-image-env.sh` — written at +/// rootfs build time from the image's `ENV` — and the glm and kimi images bake +/// `ANTHROPIC_BASE_URL` there. MEASURED on the first relayed kimi mission +/// (01a0cfc3): the node relay was bound, yet the guest dialled `api.kimi.com` +/// through egress with the proxy token, because the profile overwrote the relay +/// URL. The claude image bakes none, which is why that backend worked. The URL +/// is not a secret, so it goes in the command; credentials stay env-only. +fn with_base_url_override(cmd: String, base_url: Option<&str>) -> String { + match base_url { + Some(url) => format!("export ANTHROPIC_BASE_URL={} && {cmd}", shell_quote(url)), + None => cmd, + } +} + /// How often the tap is drained while a turn runs. /// /// A turn can last an hour; the World is meant to show what is happening now. @@ -862,7 +879,10 @@ async fn run_inside( // Concurrency here is safe because `fcagent` is thread-per-connection: the // live log tail already relies on exactly that, on a second connection, for // the whole length of a turn. So this needs no fleet-node change. - let turn_cmd = agent_command(&prompt, settings.as_deref()); + let turn_cmd = with_base_url_override( + agent_command(&prompt, settings.as_deref()), + env.iter().find(|(k, _)| k == "ANTHROPIC_BASE_URL").map(|(_, v)| v.as_str()), + ); let turn = vm.exec_attributed( &turn_cmd, None, @@ -1120,6 +1140,17 @@ mod tests { /// live, and the command's own stdout is what becomes `VmOutcome::summary`. /// A redirect would give a live view and an empty summary — which is the /// same "green and empty" shape this codebase keeps finding. + /// The relay URL must win over the image's profile, which the login shell + /// sources after fcagent sets the env; and a non-relayed turn is untouched. + #[test] + fn a_relayed_turn_re_exports_the_base_url_after_the_profile() { + let base = agent_command("t", None); + let cmd = with_base_url_override(base.clone(), Some("http://127.0.0.1:11434/kimi")); + assert!(cmd.starts_with("export ANTHROPIC_BASE_URL='http://127.0.0.1:11434/kimi' && "), "{cmd}"); + assert!(cmd.ends_with(&base)); + assert_eq!(with_base_url_override(base.clone(), None), base); + } + #[test] fn a_turn_is_teed_so_it_streams_and_still_reports() { let cmd = agent_command("do the thing", None); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cee68f5..aba0f44 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -90,7 +90,11 @@ load_if_changed() { } echo "→ sync to $BUILD_HOST" -rsync -az --delete --exclude target/ --exclude node_modules/ --exclude .git/ \ +# `target`, not `target/`: a trailing slash matches only a DIRECTORY, and a +# workstation whose target is a symlink (to an external drive) copied that +# symlink over the build host's, leaving it pointing at a path that does not +# exist there — every build since 2026-08-15 failed with "Not a directory". +rsync -az --delete --exclude target --exclude node_modules/ --exclude .git/ \ --exclude '.next/' --exclude 'frontend/public/dl/' --exclude '**/.DS_Store' \ ./ "$BUILD_HOST":~/clawmates/