fix(prime): surface daemon startup failures and carry the fork env through the host sanitizer - #211
Conversation
…rough the host sanitizer cli-bridge#194: every prime request on the lab bridge died with "Prime Agent daemon exited during startup" and a 300-byte stderr clip hid the exit code and the daemon log tail; pi on the same bridge answered 401 without saying which credential it had resolved. Cause found in the spawn path: the host executors re-filter the child env through sanitizeHostEnv, whose allowlist had no PRIME_ prefix, so the PRIME_AGENT_INTERNAL_LEGACY_OWNED_WORKER_FRONTEND=1 no-daemon contract, the PRIME_AGENT_CODING_AGENT_DIR pin and the kernel knobs never reached the fork (it went to its daemon), and any models.json apiKey name outside the allowlist was dropped, so the fork sent the bare name as the literal key. Reproduced live against fork be9e2fa0 through the bridge: origin/main answers 401 with the env name as the bearer; this branch serves the completion. - executors: PRIME_ joins the proxied prefixes; SpawnOpts.envPassthroughKeys lets a backend declare request-resolved names by exact name, and the prime backend declares the apiKey variables its models.json names - prime: a non-zero exit carries the exit code (or signal), the retained stderr in full (the fork's daemon log path and tail included) and, when the fork reached its daemon at all, says which contract that breaks - prime: inside an OS jail the per-run daemon socket dir is exposed writable (every jail mode hides the host tmpdir; the supervisor locks <socket>.lock before listening) and the prompt dir readable - pi: a 401/403 names the credential source the bridge resolved — the auth.json entry, the env var template or bare name behind providers.<p>.apiKey with its presence in the bridge env, a literal, a command, or the protected request header — never a value Tests cover the clipped-vs-full error, a real fake prime-agent whose daemon dies at startup, the real host executor delivering the contract env and a non-allowlisted apiKey name, the jail path registrations, the sanitizer allowlist, and every credential-source shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The daemon-reached hint on a startup failure claimed the build behind PRIME_BIN ignores the owned-worker frontend. A wrapper script or an env filter between the bridge and the fork drops the variable just as well — the host sanitizer did exactly that before cli-bridge#194 — so the hint now names both, and the test pins that. Verified live against fork be9e2fa0 through the bridge: origin/main under write-jail reproduces the issue's clipped "Prime Agent daemon exited during startup" 502 byte-for-byte; this branch serves the completion under the same jail, and a PRIME_BIN wrapper that strips the variable and breaks the socket dir yields a 502 carrying exit code, daemon log path and full tail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
drewstone
left a comment
There was a problem hiding this comment.
Adversarial review of this branch at b67d59c, run in a fresh worktree: pnpm install --frozen-lockfile clean, pnpm typecheck green, pnpm test 1035 passed / 11 failed (the same jail + pi-inference-isolation macOS-only set the PR body names: no Docker, /var vs /private/var, /proc/self/environ). CI test check is green.
I also ran every new/modified test against origin/main's src/ (test files from this branch, new-symbol imports stubbed): all 14 new tests and both extended assertions fail on main and pass here, so the tests do exercise the change, including the two real-process tests through hostSpawner.
Blocking
1. describePiCredentialSource prints a literal API key whenever the key is identifier-shaped
src/backends/pi-inference-transport.ts (describePiCredentialSource): the bare-name branch is referenced.length === 0 && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(apiKey) and then renders apiKey itself into the message ((${name} is set/unset in the bridge environment)). Any literal credential that happens to match identifier syntax is therefore echoed across the HTTP boundary in the 401 body. That is exactly the case the docblock says exists: in the upstream pi 0.8x line a bare identifier IS a literal key. Checked by calling the function directly with a models.json whose apiKey is a literal:
a9f3e1c2b4d5e6f7a8b9c0d1e2f3a4b5(hex token starting with a letter) -> rendered verbatimsk_live_abcDEF123(underscore-style key) -> rendered verbatimsk-live-x(dash) -> "the literal apiKey under ..." (safe)
The "never renders a literal key" test only uses dashed literals, so it cannot catch this. The route puts err.message straight into the 501 JSON body, so this is a credential leak to the caller (and to any log that stores response bodies).
Fix: never echo the identifier. Report presence without the name, e.g. the bare identifier under providers.<p>.apiKey in <models.json> (an env var of that name is set|unset in the bridge environment), and add a test with an identifier-shaped literal (abcdef0123456789) asserting not.toContain.
2. Widening piFailureKind to the full 64 KiB stderr makes the prime exit code/status wrong on incidental 401/403 substrings
src/backends/prime.ts exit path: piFailureKind(detail) now runs /401|403|token expired|forbidden|unauthorized/i over sawError + the whole retained stderr instead of a 300-byte clip. The daemon log tail this PR deliberately includes is full of millisecond timestamps and bundle line numbers; checked directly: piFailureKind('[2026-09-10T04:50:15.401Z] supervisor: ...') and piFailureKind('chunk.js:4031 at foo') both return not_configured. The route maps that to HTTP 501 not_configured (never retried) instead of 502 upstream for what is a daemon crash. The PR's own fixture asserts err.code === 'upstream' for a daemon-startup failure; shift the fixture's timestamp by 16 ms and that assertion fails. The regex is pre-existing, but this PR is what feeds it kilobytes of timestamped log text, and its whole point is an accurate failure report.
Fix: classify from the structured signal only (sawError / the rpc error / turnFailure), or from a bounded first line of stderr, not the log tail; keep the full tail in the message. A test with a .401Z timestamp in DAEMON_STARTUP_STDERR expecting upstream pins it.
Non-blocking
PRIME_as a proxied prefix insanitizeHostEnvchanges the env of every host-spawned backend, not only prime:claude,codex, andopencodespreadprocess.env, soPRIME_BIN,PRIME_MODELS_JSON,PRIME_PERSISTENT_AGENT_DIR,PRIME_PROGRESS_MS, and any operator key namedPRIME_*(the test suite itself usesPRIME_TEST_ROUTER_KEY) now reach those CLIs and their tools. Not a break and consistent with the existingTANGLE_/OPENAI_prefixes, but every fork knob you name isPRIME_AGENT_*; the tighter prefixPRIME_AGENT_(or the five exact names) gives the same fix with a smaller blast radius. The prime backend itself is unaffected becauseprimeProcessEnvironmentstarts from a neutral allowlist.- Pi: the pre-spawn credential resolution failure (
resolvePiAuthCredentialthrowing ->backend pi cannot establish isolated inference auth for <p>/<m>) is anot_configuredon the same credential and still does not name the source; an unset$VARin the pi line that treats bare names as env vars ends there, not at a 401.describePiCredentialSourceis already computed on that path; appending it there would close the last gap in "ask 2". - Prime exit paths: startup exit and signal kill go through
describePrimeExitwith the full retained stderr (checked the buffer:render()with no argument returns head + tail with an explicit[... N bytes omitted ...]marker, never[... clipped ...]). Spawn ENOENT does not: it staysprime spawn failed: spawn <bin> ENOENT(upstream, 502) with no exit code, which is correct since nothing ran and there is no daemon log; arguably it should becli_missing(503), but that is pre-existing. describePrimeExitwithexitCode: nulland no signal rendersprime exit unknown: ...; with empty stderr and no rpc error the message isprime exit N: exit N. Both pre-existing shapes, cosmetic.- The
[bridge]hint names two causes (build ignores the frontend, wrapper/env filter drops the variable). A build that honors the variable for--mode rpcbut not on some other path (e.g.--continue) would be a third; fine as a hint, just not exhaustive. - Jail:
--bindofextraWritablePathsis after--tmpfs /tmpinlinux-bwrap.ts, so the socket dir does reappear;registerJailReadableis a no-op withoutreadConfine, which is the correct scope for the prompt dir.
…sify daemon crashes from structured signals
describePiCredentialSource rendered the apiKey string itself whenever it
matched identifier syntax, so a hex or `sk_live_` literal — which the
upstream pi 0.8x line treats as the key — crossed the HTTP boundary in the
501 body. It now reports the bare identifier by presence only ("an env var
of that name is set|unset"), and the pre-spawn resolution failure names the
credential source as the 401 path already did.
The prime exit path ran piFailureKind over the whole retained stderr, so a
`401` inside a daemon-log timestamp (`15.401Z`) or a bundle line number
(`chunk.js:4031`) turned a daemon crash into a never-retried
not_configured. primeExitFailureKind classifies from the rpc error or
`error` event when there is one, else from the first non-empty stderr line;
the message keeps the full tail. piFailureKind matches 401/403 only as
whole tokens.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Both blocking findings are fixed in 1a9687e.
Local: Not changed: the |
describePiCredentialSource found template names with a looser scan than
pi's own parser, so literal key text crossed the HTTP boundary in the 401
and pre-spawn failure messages as a "variable name":
- `$$` and `$!` escapes were deleted rather than consumed, joining a name
with the literal text after them (`$ROUTER_PREFIX$$9fQxT2vLm` rendered
`ROUTER_PREFIX9fQxT2vLm`);
- braces were optional, so an unclosed `${` or a non-identifier brace body,
which pi keeps as literal key text, rendered its leading characters
(`k3y${fQxT2vLmZ` rendered `fQxT2vLmZ`).
piConfigValueEnvNames ports pi's template scan
(core/resolve-config-value.ts parseConfigValueTemplate), so only names pi
itself reads are rendered. A 20,000-case fuzz against the installed pi
0.84.2 parser found 976 divergent inputs before this change and none
after. The auth.json credential type is rendered only as pi's own
`api_key` / `oauth`, since the field is file content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
drewstone
left a comment
There was a problem hiding this comment.
Independent adversarial re-review of 1a9687e, plus fix 612c367
Verdict: both blocking findings from the 06:16Z review are fixed in 1a9687e. This re-review found one more blocking credential leak in the same function; 612c367 fixes it. No blocking finding remains.
Setup: fresh worktree at origin/fix/prime-daemon-startup-diagnostics (1a9687e). pnpm install --frozen-lockfile clean. pnpm typecheck clean on 1a9687e and on 612c367.
Blocking finding 1 (identifier-shaped literal echoed): fixed
- I ran the 1a9687e test files against b67d59c's
src/.never echoes an identifier-shaped literal keyfails there withexpected 'the bare name under …' not to contain 'abcdef0123456789'. It passes on 1a9687e. - Independent check: 4 identifier-shaped literals (hex,
sk_live_…,ghp_…), each with and without an env var of that name. Also checked: env values behind$VAR,${VAR}, andBearer ${VAR}; an auth.jsonkey; an auth.json entry that is a bare string; a!echo <secret>command. No value appears in the output.
Blocking finding 2 (401 inside timestamps and line numbers): fixed
- On b67d59c's
src/,classifies a daemon crash as upstream …fails withexpected 'not_configured' to be 'upstream', and so does thepiFailureKindwhole-token test. Both pass on 1a9687e. primeExitFailureKindclassifies from the rpc error orerrorevent when one exists, else from the first non-empty stderr line (300-byte cap). The message keeps the whole retained stderr.
New blocking finding: the template scan printed literal key text as a variable name (fixed in 612c367)
describePiCredentialSource found template names with apiKey.replace(/\$[$!]/gu, '') followed by /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu. That scan does not match pi's own parser (core/resolve-config-value.ts parseConfigValueTemplate). Pi keeps two kinds of input as literal key text, and the bridge rendered that text as a name in the 401 and pre-spawn error bodies:
apiKey |
pi 0.84.2 reads | 1a9687e rendered |
|---|---|---|
$ROUTER_PREFIX$$9fQxT2vLm |
env ROUTER_PREFIX + literal $9fQxT2vLm |
ROUTER_PREFIX9fQxT2vLm is unset … |
k3y${fQxT2vLmZ |
literal (unclosed ${) |
fQxT2vLmZ is unset … |
${xY7_q-9fQxT2} |
literal (brace body is not an identifier) | xY7_q is unset … |
The cause: the escape is deleted instead of consumed, and the braces are optional. I fuzzed 20,000 generated apiKey strings against the installed pi 0.84.2 getConfigValueEnvVarNames. 976 inputs rendered a name that pi does not read.
612c367 adds piConfigValueEnvNames, a port of pi's scan, so only names that pi reads are rendered. The same fuzz finds 0 divergent inputs. The auth.json type is file content, so it now renders only as pi's api_key or oauth, and anything else renders as unrecognized-type. Two new tests fail on 1a9687e and pass on 612c367: expected 'the env var template under providers.…' not to contain '9fQxT2vLm' and expected 'stored sk-typo-secret-in-type credent…'.
What each credential-source path renders on 612c367:
- request header: a constant
- auth.json: its path, plus
api_key,oauth, orunrecognized-type - pi built-in auth: paths
!command: the key path- template: the names pi reads, each marked set or unset
- bare identifier: presence only
- literal: the key path
The pre-spawn failure appends the same string. The route renders err.message only, not cause.
CI test on 612c367 (Linux) passed: 1076 tests, 1055 passed, 21 skipped, 0 failed.
New tests against origin/main's src/
I copied the branch test files onto origin/main (30038a6). 22 new or changed tests fail there, not counting the 2 macOS-only isolation failures that fail on both, and all 22 pass on the branch. The real-process env test echoes legacy=;dir=;key=;ambient= on main, because the sanitizer dropped all three variables. On the branch it echoes legacy=1;dir=…/.prime/agent;key=sk-named-by-models-json;ambient=.
Full suite on this macOS host
| checkout | tests | passed | failed | skipped |
|---|---|---|---|---|
| origin/main 30038a6 | 1056 | 1021 | 11 | 24 |
| 612c367 | 1076 | 1041 | 11 | 24 |
The 11 failures are the same tests by name on both checkouts: docker-executor 3, failure-attribution 1, jail 5, pi-inference-isolation 2. The causes are no Docker, /var vs /private/var, and /proc/self/environ. This PR's modified pi-inference-isolation tests are not in that set; they ran and passed here. test:runtime-consumer passes.
One full run at 1a9687e also failed pi-native … returns transport failure when Pi consumes a response …: a 2 s waitFor timed out under full-suite load. The test passed 5 of 5 runs alone and did not fail in the 612c367 full run. The test does not exercise the code this diff changes.
Other backends
- The new
sanitizeHostEnvparameter defaults to[], and only prime passesenvPassthroughKeys. The docker executor has its own allowlist. Prime always runs on the host or scoped-host spawner, becauseserver.tsconstructs it without a spawner. - Host-spawned backends that spread
process.env(claude, codex, opencode, kimi, gemini) now also receive the bridge'sPRIME_*variables. None of those CLIs readsPRIME_*, so their behavior is unchanged. piFailureKindnow matches 401 and 403 only as whole tokens, which also applies to pi.HTTP 403,(401), and401: {…}still classify asnot_configured. A status joined to a word character (HTTP401,_401) now classifies only if the text also containsunauthorized,forbidden, ortoken expired.
Non-blocking
- The prime exit message now carries up to 64 KiB of the child's stderr without redaction. Every other backend clips stderr to 300 bytes, and claude also redacts
Bearerandsk-shapes. The prime child is the one child with real provider keys in its env; the pi child gets a scoped key. I found no fork path that prints a key to stderr:resolve-config-valuediscards command stderr, and the daemon log holds supervisor lines. So this risk is a hypothesis. Removing the exacthome.apiKeyEnvvalues from prime's error messages would guarantee it. Follow-up. primeExitFailureKindreads the first stderr line. For an uncaught Node throw, that line isfile:///…:LINE, so an auth error thrown uncaught classifies asupstream. A bundle line number of exactly 401 or 403 on that line would classify asnot_configured. The impact is low, because the fork reports provider auth failures through rpc andturn_end. Skipping Node's location, source, and caret lines would tighten the rule.- The
PRIME_prefix is broader than thePRIME_AGENT_*knobs the fork needs, as the first review noted. It is left as is. - The PR body lists
TANGLE_ROUTER_KEYamong the dropped apiKey names, butTANGLE_is on main's prefix list, so that name always passed. The dropped-name 401 applies to names outside the allowlist, which the PR reproduced withLIVE_FAKE_KEY. The pi 401 on the lab bridge does not pass throughsanitizeHostEnv: the pi resolver runspi auth print-api-keywith the bridge'sprocess.env. This PR makes that 401 name its credential source but does not establish its cause. describePiCredentialSourcereads auth.json and models.json on every request that has no protected credential header, including successful requests. Its output is used only on failure, so the reads could run only when a failure needs them.
Fixes #194. Part of tangle-network/discovery#165 (the lab-side ticket for the same failure).
What was wrong
primerequests on the lab bridge died withPrime Agent daemon exited during startupand the bridge clipped the daemon log tail and exit code out of the error;pion the same bridge answered 401 without saying which credential it had resolved.Cause established in the bridge's spawn path. The host executors re-filter every child env through
sanitizeHostEnv(src/executors/host.ts), whose allowlist had noPRIME_prefix. So:PRIME_AGENT_INTERNAL_LEGACY_OWNED_WORKER_FRONTEND=1— the no-daemon contract the prime backend is built on — never reached the fork, which therefore went to its background daemon (ensureDaemonRunning, the frame in the issue's stack).--daemon-socketdir under the host tmpdir is hidden (fs-jail replaces/tmpwith an empty tmpfs, write-jail binds/read-only), so the daemon supervisor dies onmkdir <socket>.lock(ENOENT / EACCES) — the line the 300-byte clip hid. The lab's 8901 env carriesBRIDGE_JAIL_RO_PATHS/BRIDGE_JAIL_RW_PATHS, i.e. the Linux fs-jail, so this is its shape.PRIME_AGENT_CODING_AGENT_DIR(the fork's only agent-dir name) and the kernel knobs were dropped too;PRIME_PERSISTENT_AGENT_DIRwas silently ignored.models.jsonapiKey name outside the allowlist (DEEPSEEK_API_KEY,LIVE_FAKE_KEY, …) was dropped, so the fork resolved the bare name as the literal credential. (TANGLE_ROUTER_KEYalways passed through the existingTANGLE_prefix.)Not the cause: the socket path length (
/tmp/prime-sock-XXXXXX/d.sockis 27 bytes on Linux, ~75 under the macOS tmpdir; both far under the 104/108-byte AF_UNIX caps) and the pinned HOME/XDG (the fork bootstraps.prime/agentunder any HOME; the bridge-owned one is writable in every jail mode).Reproduced live (fork built at the pinned
be9e2fa0, through the bridge, fake OpenAI-compatible provider on loopback)origin/main501 prime assistant turn failed: 401 Invalid API key; provider sawBearer LIVE_FAKE_KEY(the env name)origin/mainwrite-jail502 prime exit 1: file:///…/chunk-2PPDA3IT.js … [... clipped ...] … ensureDaemonRunning (…:60733:3)— the issue's error byte-for-byte (same line numbers 60719/60733)200,PONG-LIVEcompletionwrite-jail200,PONG-LIVEcompletionPRIME_BINwrapper that strips the frontend variable and breaks the socket dir502whose message carriesprime exit 1, the fork'sRecent daemon log (<agent dir>/logs/d.sock.<id>.log):line, the full tail (ENOENT … mkdir '…/d.sock.lock'),Node.js v22.23.2, and a[bridge]line naming the socket it handed over and the contract that was lostThe lab bundle's chunk hash (
chunk-IAW7YJNT) differs from the pinned build's (chunk-2PPDA3IT) at identical line numbers, so the lab fork is a nearby but not identical build; with this branch the daemon is not reached at all for--mode rpconbe9e2fa0, and if the lab build reaches it anyway the error now says so and carries the daemon log.Changes
PRIME_joins the proxied env prefixes;SpawnOpts.envPassthroughKeyslets a backend declare request-resolved names by exact name. The prime backend declares the apiKey variables itsmodels.jsonnames.extraWritablePathsafter--tmpfs /tmp, so it wins; the supervisor locks<socket>.lockbefore listening), and the prompt dir is registered readable (an invisible--append-system-promptpath is taken by the fork as literal prompt text).auth.jsonentry, the env-var template behindproviders.<p>.apiKeywith whether each variable is set in the bridge environment, a bare identifier (reported by presence only, since upstream pi treats it as the key itself), a literal, a!command, or the protected request header — never a value or the apiKey string.Tests
prime-agentthroughhostSpawnerwhose daemon dies at startup (log written under the agent dir, launcher error on stderr)PRIME_*lookalike is still covered by the sanitizer allowlist tests)sanitizeHostEnvprefix and exact-name passthroughpnpm typecheckgreen.pnpm test: CI (Linux) green. On this macOS host 11 tests indocker-executor,failure-attribution,jail,pi-inference-isolationfail identically in anorigin/mainworktree (no Docker,/var→/private/var,/proc/self/environ); every other suite (1035) andtest:runtime-consumerpass.Deploy note for the lab bridge (port 8912)
Not restarted from this PR. After merge, on the lab host: find the checkout the 8912 unit runs (
ps -o args= -p <pid of the 8912 listener>shows thesrc/server.tspath),git pullmain there,pnpm install --frozen-lockfile, restart that unit with its existing env —BRIDGE_BACKENDSincludingprime,PRIME_BIN,PRIME_MODELS_JSON, the router key variable the models.json names (TANGLE_ROUTER_KEY), and the jail variables (BRIDGE_JAIL_MODE,BRIDGE_JAIL_RO_PATHS,BRIDGE_JAIL_RW_PATHS) if set. If~/.cache/cli-bridge/prime-agentis not onbe9e2fa0, rebuild the fork at the pin (PRIME_AGENT_REF=be9e2fa0 pnpm install:harness prime).Proof:
POST /v1/chat/completionswith{"model":"prime/tangle-router/deepseek-v4-flash","messages":[{"role":"user","content":"reply with exactly the word pong"}]}returns 200 withchoices[0].message.contentandusage.model_requests: 1; a 502 now carries the full daemon log tail instead of[... clipped ...]; api/tangle-router/...401 now ends in(credential source: …).Review fixes (1a9687e)
describePiCredentialSourceno longer renders the apiKey string for a bare identifier:the bare identifier under providers.<p>.apiKey in <models.json> (an env var of that name is set|unset in the bridge environment). Test covers hex,a9f3…-style, andsk_live_identifier-shaped literals withnot.toContain. The pre-spawn resolution failure (cannot establish isolated inference auth) now names the credential source too.primeExitFailureKind: the rpc error /errorevent when present, else the first non-empty stderr line (300-byte cap); the message keeps the whole retained stderr.piFailureKindmatches401/403only as whole tokens, so15.401Zandchunk.js:4031no longer classify. Tests: the daemon fixture with a.401Ztimestamp and a:4031line number staysupstream; a401 Unauthorizedrpc error or first stderr line staysnot_configured.Re-review fix (612c367)
describePiCredentialSourcenow finds template names withpiConfigValueEnvNames, a port of pi's own template scan. The earlier scan rendered literal key text as a variable name:$A$$tailrenderedAtail, andk${tailrenderedtail. A fuzz of 20,000 inputs against the installed pi 0.84.2 parser found 976 divergent inputs before this change and 0 after.api_key,oauth, orunrecognized-type.🤖 Generated with Claude Code