fix(microvm): the relay URL must survive the login profile; deploy.sh must not ship a target symlink
deploy / test (push) Successful in 5m25s
deploy / build (push) Successful in 6m12s

- 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) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-23 14:41:30 -05:00
co-authored by Claude Opus 5.5
parent af70c5416e
commit eb1137ed68
2 changed files with 37 additions and 2 deletions
+32 -1
View File
@@ -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. /// 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. /// 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 // Concurrency here is safe because `fcagent` is thread-per-connection: the
// live log tail already relies on exactly that, on a second connection, for // 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. // 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( let turn = vm.exec_attributed(
&turn_cmd, &turn_cmd,
None, None,
@@ -1120,6 +1140,17 @@ mod tests {
/// live, and the command's own stdout is what becomes `VmOutcome::summary`. /// 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 /// A redirect would give a live view and an empty summary — which is the
/// same "green and empty" shape this codebase keeps finding. /// 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] #[test]
fn a_turn_is_teed_so_it_streams_and_still_reports() { fn a_turn_is_teed_so_it_streams_and_still_reports() {
let cmd = agent_command("do the thing", None); let cmd = agent_command("do the thing", None);
+5 -1
View File
@@ -90,7 +90,11 @@ load_if_changed() {
} }
echo "→ sync to $BUILD_HOST" 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' \ --exclude '.next/' --exclude 'frontend/public/dl/' --exclude '**/.DS_Store' \
./ "$BUILD_HOST":~/clawmates/ ./ "$BUILD_HOST":~/clawmates/