fix(agent-gaia): stop the npm sidecar attaching to a foreign server - #3078
Conversation
A second `npx @amd-gaia/gaia serve` printed a ready URL for a server it did
not own. Its own sidecar had already died on the bound port, but the
incumbent answered /health, so the start "succeeded" and the later shutdown
logged "already exited" while the real server kept running. startSidecar now
asserts its own child is alive before returning, and fails naming the port
and the likely cause.
Eight other faults in the same package, each turning a real failure into a
wrong answer or an error we caused ourselves:
- Importing the library changed a host app's error handling. The crash and
signal handlers reaped every sidecar before checking whether the host had
its own handler, so a host-handled exception killed the sidecar and the
next request got an unexplained ECONNREFUSED.
- Hiding our `gaia` shim from the TUI removed our whole bin directory from
its PATH, which on a Homebrew or pipx layout also took python3,
lemonade-server, and the real `gaia` with it. A shared directory now moves
to the end; only one holding nothing but our shim is dropped.
- resolveSidecarPath/resolveTuiPath spawned whatever sat at the predictable
cache path with no hash check, contradicting fetch.ts's claim that the SHA
verify is the security boundary. Both verify against binaries.lock.json
now, with an explicit { verify: false } opt-out for self-built binaries.
- `run --port`, `serve --component`, and `serve --cache-dir` parsed fine and
were then ignored. They are refused, naming the command that reads them.
- A ~200MB artifact was buffered whole in memory; it now streams to the
staging file while hashing, still verified before the rename. A failed
rename (a running sidecar holding the .exe) says what to do rather than
printing a raw stack.
- taskkill's exit code and stderr were discarded, so "Access is denied"
surfaced ten seconds later as a generic timeout.
- shutdown de-registered the sidecar before killing it, so one surviving
both kill windows was invisible to the process-exit reaper and became a
permanent orphan holding the port.
- `serve` never removed its signal handlers, and --base-url accepted any
scheme; a plaintext mirror now needs --allow-insecure-base-url.
Tests 101 -> 135, none skipped on Linux. They cover the orphan-reaping
handlers (previously zero coverage — every spawning test passed
autoCleanup:false), a start against an occupied port, per-command flag
scoping, and src/url.ts, which had no test file at all.
`npx @amd-gaia/gaia` staged a verified sidecar binary but never recorded the install, so the terminal UI treated `gaia-agent` as its own stdio child and the chat filled with uvicorn's startup log instead of answering. The daemon and the TUI both decide "this agent is installed" from a `.installed` sentinel next to the binary; with it present the TUI uses daemon transport and the daemon supervises the process and mints its token. The sentinel is written for the sidecar only — the TUI is not a hub agent — and on a cache hit as well as a fresh download, because users who already ran an earlier release have a verified binary with no sentinel and would otherwise stay broken forever. Shape and field names are a cross-repo contract with `InstalledAgent.to_dict()` in `gaia.hub.installer`. Three fields are load-bearing: the daemon's `_hub_installed_binary` ignores the install unless `artifact_kind` is `binary` with a non-empty `executable` and `artifact_sha256`, and it re-hashes the file against that SHA — so the sentinel carries the hash actually verified, not the lock's nominal value. Verified end-to-end by feeding a sentinel this code wrote to the real `installer.read_sentinel`, which accepted it and passed all four daemon gates. Docs brought back in line with the code, per the rule that a functional change updates every doc describing it: - SPEC gains §4.1 for the sentinel, and its exit-code, error, and public-API tables now cover per-command flag scoping, the two new error classes, and the fact that the resolve helpers hash the binary. - SKILL's "run once, resolve at runtime" advice was wrong the moment the resolve helpers started verifying — resolving is now startup work, not per request. Its PATH gotcha claimed we strip our whole bin directory, which is no longer what happens. - README's flag table gains the per-command scope and the new opt-out flag, and "Where things land" gains the sentinel. - CHANGELOG covers this PR and the previous one. Tests 135 -> 141 (Linux, none skipped); 136 pass on Windows with the 5 POSIX-only process tests skipped.
Nothing ran the `@amd-gaia/gaia` launcher's test suite on a pull request. Only release_agent_gaia.yml touched the package, at release time, so a change that broke `npx @amd-gaia/gaia` stayed green until the release gate -- the same gap test_gaia_agent.yml closed for the Python side, and the reason the lifecycle bugs in this PR shipped. Mirrors test_agent_email_npm.yml, including its thin-package assertion: the tarball must never carry a platform binary, since the sidecar and the TUI are fetched from R2 and SHA-256 verified against binaries.lock.json at run time.
Request changesThis hardens the The "second Fetching for another platform now corrupts the local install record. The memory win misses the path users actually hit. Fresh downloads now stream, but the far more common already-cached run still reads the whole ~200MB binary into memory to check its hash — the same peak the changelog says was removed. Real-world evidenceN/A — no evidence bundle was produced for this PR, and this environment can't reach the GitHub API, so I couldn't check whether the PR description links evidence elsewhere. The changed surface is a CLI ( 🔍 Technical detailsIssues🟡
The test passes only because it forces the inverse timing: A deterministic version of the same check is a pre-flight probe before the spawn: // in startSidecar, before spawnSidecar(opts)
const port = opts.port ?? DEFAULT_PORT;
if (await portAnswers(`http://${opts.host ?? DEFAULT_HOST}:${port}`)) {
throw new SidecarExitedError(/* the same "already bound to port N" message */);
}Keeping 🟡 The
drops Gate both call sites on the fetch being for this host: 🟡 The cache-hit path still buffers the whole binary (
Nits🟢 Unknown and boolean flags are still silently ignored ( 🟢 Strengths
|
`gaia_agent_gaia` is the PyPI distribution; the module a reader would open is `gaia_agent.server`. Following the comment as written finds nothing.
|
Verdict: Approve This is a careful, well-tested batch of fixes to the A few things worth noting:
No blocking issues found. 🔍 Technical details
Thin-package invariant check in CI ( |
Whether the incumbent server's reply or our own child's death reaches startSidecar first is a race, and it decided which error the user got. Windows won it and reported the port conflict correctly; Linux CI lost it and reported a plain health timeout — so `gaia serve` against an already-bound port told the user nothing about the port. The health wait aborts the moment the child exits, so losing that race is not an edge case, it is a fast machine. Re-probing settles what the failure really is: something else answering means a port conflict, nothing answering means our sidecar just died. The timeout error is unchanged for the second case. The regression test delays the stub's /health past the child's exit, which pins the losing order on every platform instead of leaving it to timing — it fails with the re-probe removed, reproducing the CI message verbatim.
|
Verdict: Approve with suggestions This is a well-executed batch of hardening fixes. The sentinel write, streaming download, binary-verify-before-spawn, signal-handler cleanup, and foreign-server detection are all correctly implemented and tested. One 🟡 finding and one 🟢 nit below. 🟡 The catch block inside The primary case (second 🟢 The ignored-flags test includes 🔍 Technical details
Alternatively, a simpler fix is to only return when } catch (e) {
if (!(e instanceof GaiaError)) return; // ECONNREFUSED etc. — nothing there
// GaiaError means something answered but is not us
}Either way, the intent is: treat "connection refused / timed out" as "port is free", and let "port has a live server that misbehaves" fall through to Missing test cases for
["--port", ["run", "--port", "9000"]],
["--component", ["run", "--component", "tui"]],
["--port", ["fetch", "--port", "9000"]], |
The port-conflict guard only fired if our own child had already died by the time the health and version probes finished — and in the real conflict that ordering is backwards. An incumbent answers `/health` in milliseconds while the ~200MB frozen sidecar is still unpacking, so the child is very much alive, the check passes, and `gaia serve` prints a ready URL for a server it does not own. The earlier tests passed only because they forced the inverse ratio. The port is now probed before anything is spawned, so the refusal no longer depends on which side wins a race. `assertOurs` and the health-wait re-probe stay as backstops for the narrower case where something binds afterwards. Two more from the same review: - A `--platform` fetch for another OS wrote the local install record, so the daemon would find a wrong-architecture binary, re-hash it successfully, and try to exec it. The record is now written only for a sidecar fetch that matches this host. - `fileSha256` still read the whole binary to check a cache hit, which is the ~200MB peak the streaming download had just removed. It hashes in chunks now, so the claim in the changelog is true on both paths.
The comment claimed the holder's ready-file pinned the ordering. It does not: it only guarantees the holder is listening before the child exits, and which backstop then fires still depends on whether a health probe lands before or after that exit. Both raise the same error, so the test is stable either way — but neutering one backstop alone still passes it, which the comment now says rather than implying a determinism the test does not have.
|
Verdict: Approve with suggestions A thorough set of bug fixes and security improvements (sentinel write, port pre-flight, streaming downloads, binary verification at resolve time). Tests are extensive. One 🟡 finding and two small nits. 🟡 Several tests hardcode ports that the CI machine may already have bound — those tests then fail with The test 🟢 🟢 🔍 Technical details🟡 Hardcoded ports
// uses port 8189 — would PortInUseError if taken
const p = startSidecar({ binaryPath: diesInstantly(), port: 8189, ... });
await expect(p).rejects.toBeInstanceOf(HealthTimeoutError);Lines ~2197, ~2228, ~2253 use 8195, 8194, 8193 respectively. Fix: const free = await freePort();
const p = startSidecar({ binaryPath: diesInstantly(), port: free, ... });For tests that need a listener already present on the port, bind a server first and pass its dynamic port. 🟢 Backtick escape in help text ( Using a plain string or a regular template literal avoids the ``` reader-speed bump. 🟢 |
|
All three fixed — and the first one was a fair hit on the core of the change, not a detail. The guard didn't fire in the real case. You're right: an incumbent answers The cross-platform sentinel was a regression I introduced. The cache-hit hash now reads in chunks, so the changelog's memory claim is true on both paths instead of just the download. The changelog entries for the port conflict and the sentinel were rewritten to describe what the code now actually does — the old port-conflict wording described the mechanism you showed doesn't happen. 🔍 Technical details145 tests pass (137 before). Each fix was verified by neutering it and confirming the new test fails. One test comment was walked back as overclaiming: the holder's ready-file only guarantees the holder is listening before the child exits, not which backstop then fires. Both raise the same error so the test is stable, but neutering one backstop alone still passes it — the comment now says that rather than implying determinism it doesn't have. |
|
Verdict: Approve with suggestions Solid batch of fixes: port pre-flight, streaming downloads, chunked hashing, sentinel writing, signal ownership, and a new CI job. Documentation updated across all four surfaces (README, SPEC, SKILL, CHANGELOG) in one commit — exactly right. One 🟡 finding in the new tests risks making the CI job you just added intermittently unreliable. 🟡 The new CI job you're wiring may fail spuriously because the test it exercises hardcodes a port.
The rest of the hardcoded ports ( 🟢 🔍 Technical detailsPort 8189 — // before
const p = startSidecar({
binaryPath: diesInstantly(),
port: 8189, // ← hardcoded; if taken, portInUse() → PortInUseError
autoCleanup: false,
healthTimeoutMs: 3_000,
});
// after
const port = await freePort(); // freePort() defined at line ~2050 in the same file
const p = startSidecar({
binaryPath: diesInstantly(),
port,
autoCleanup: false,
healthTimeoutMs: 3_000,
});
|
Three ways
npx @amd-gaia/gaiacould mislead you, and one reason none of them was caught.It reported success for a server it didn't own. Run
gaia servetwice: the second sidecar fails to bind 8141 and exits, but the health probe gets an "ok" from the first instance and wins the race, so the CLI prints a ready URL for someone else's server. Ctrl+C then reports "already exited" and leaves the real one running.It killed your sidecar when your own app handled an error. The crash and signal handlers reaped before checking whether we own the exit, so a host with its own
uncaughtExceptionlistener — which keeps running by design — lost its sidecar anyway and got an unexplained ECONNREFUSED on the next request. Merely importing this package changed the semantics of the host's error handling.It removed your tools from the child's PATH. Stripping our own
gaiashim dropped the shim's whole directory, so on a Homebrew or~/.local/binlayoutpython3,lemonade-serverand the realgaiadisappeared — and the TUI then reported "thegaiaCLI is not on PATH", an error we had caused. A directory holding only our shim is still dropped; a shared one now moves to the end of PATH instead.And
gaia runnever finished the job it documents. It stages a verified binary where the daemon looks, but never wrote the.installedrecord that means "completed install" — so the TUI treated the frozen REST sidecar as its own stdio child and filled the chat with uvicorn's startup log. Writing it also means the daemon supervises the process and mints its caller-auth token, which is the intended path.The reason all of this shipped: the package had no PR-time CI at all. Only the release workflow touched it. That's added here, mirroring the email package's.
Also: the ~200MB download is now streamed and hashed incrementally rather than buffered whole (~400MB RSS peak), with the integrity gate unchanged — unverified bytes still never reach the final path.
🔍 Technical details
startSidecarasserts the child is alive after the health wait, and the caller's abort signal is linked intogetJsonso an in-flight probe aborts the moment the child dies rather than at the next poll.weOwnTheExit()gates the reap onlistenerCount === 1;process.on("exit", reapAllSync)stays as the backstop.InstalledAgent.to_dict(). It is written on a cache hit too, so installs staged by an earlier release repair themselves.resolveSidecarPath/resolveTuiPathnow verify againstbinaries.lock.jsonbefore returning a path to spawn; per-command rejection of--port/--component/--cache-dirwhere they do nothing;taskkillexit code + stderr surfaced;shutdownde-registers only on confirmed exit;cmdServeremoves its signal handlers;--base-urlrequires https (with--allow-insecure-base-url); guardedJSON.parse; strict-digit port parsing.spawnSidecar. That is a behaviour change to a published package and a product decision — raised in docs(agent-gaia): correct the npm sidecar docs against the code #3076.Test plan
cd hub/agents/gaia/npm && npm ci && npm run build && npm test— 136 pass (101 before)hub/agents/gaia/npm/**gaia.hub.installer.read_sentineland the daemon's_hub_installed_binary, returns a verifiedFetchResult(using hub-installed verified binary … v0.1.1)taskkillbranches (win32-only, no spawnable long-lived surrogate) andshutdown's survivor path (nothing can ignore SIGKILL)