fix(agent-server): rebuild the execution env per spawn so a removed .env key stops applying (#1999) - #2010
Conversation
|
Resolve by running |
…env key stops applying (#1999) The credential endpoints mirrored every submitted key into the long-lived agent-server process's `os.environ`, and all five runtime spawn sites passed `env={**os.environ, ...}`. The `.env` file was never read back into that process, so the file an operator inspects and the environment executions receive were two independent channels kept in sync by one code path, in one direction, for present keys only. A key removed from `.env` by any non-mirroring write path — SSH, `docker exec`, or an agent editing its own `.env` — therefore kept reaching every subsequently-spawned execution until the container restarted, and was invisible to `/proc/<pid>/environ` (an exec-time snapshot) and to `docker exec` (the container baseline, not the mutated process env). Credential revocation silently failed with no inspection route that could reveal it. `services/execution_env.py` makes the execution environment a pure function of three inspectable inputs, rebuilt per spawn: the container baseline captured at import, `.env` parsed fresh (authoritative for credentials), and a small named overrides dict. Overrides are applied after the file, not before as the issue sketched — `/api/credentials/reload-token` deliberately never writes `.env`, so file-last would let a stale token in the file beat an explicit rotation and re-open #1089 from the other side. `None` means force-unset, which a dict merge cannot express and `remove_api_key` needs. The process mirror stays, because in-process readers (error classifier, AGENT_RUNTIME, the sanitizer's redaction set) depend on it, but gains a delete phase that restores the container baseline rather than popping blind, and only ever undoes keys it itself wrote. `.env` may no longer set loader/exec-redirecting names (PATH, LD_PRELOAD, BASH_ENV, …): it is agent-writable and is now read at every spawn, a wider trigger than the old backend-only mirror. `GET /api/credentials/status` gains a per-key `env_drift` report — names only, never values — so this class is one call to diagnose instead of a multi-hour forensic hunt. Closes #1999 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…127 anchor CI caught a real regression, not a stale anchor: `test_ent127_predicate::test_source_anchor_still_present` failed on all three seeds. The ent#127 "is this credential set" predicate (`credential_requirements_service._env_pairs`) is *defined* as agreement with the agent's export loop, and its source is spliced into an in-container probe — and this branch had replaced that loop with a parser that also improved the parsing: one matched quote pair instead of peeling every layer, unescaping what the writer escapes, honouring `export `, and a key-name filter. Each of those is a real improvement, and every one of them silently moves that predicate. `export KEY=v` would have flipped from missing to set; `KEY=""""` from empty to non-empty; `K-E-Y` would have stopped reaching the child at all — a behaviour narrowing inside a fix whose entire subject is that credentials must keep behaving predictably. So the parsing is reverted to byte-faithful, and this branch keeps only the lifecycle change it is actually about: the execution env is rebuilt per spawn from the file, and the mirror gained a delete phase. `_env_pairs` needs no change, so ent#127's predicate and its spliced probe are untouched. What did move is the anchor: the loop now lives in `execution_env.parse_env_file` rather than inside `routers/credentials.py`. Re-pointed there, plus a new guard that fails if the set-only mirror ever comes back — the shape that caused #1999 in the first place. Improving the parse is worth doing; it belongs in its own change, with ent#127's parity test as the gate rather than the casualty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's seed-99999 shard hit the job's 25-minute timeout while the other two seeds finished in ~10. The suite was still progressing (96% when the axe fell), so it was slow rather than wedged — and the one seed-dependent hazard this branch adds is right here. The fixture replaced `os.environ` with a plain dict for the duration of each of 36 tests. That is hermetic for the test and hostile to the rest of the process: the mapping stops writing through to the C environment, and any thread still alive from another test — an APScheduler tick, an event-bus dispatcher — sees an environment with no PATH or HOME for that window. Whether such a thread overlaps these tests is decided by pytest-randomly's seed, which is the shape of a failure that appears under one seed and not the other two. Now: an autouse fixture snapshots and restores the CONTENTS of the real mapping, and the baseline is injected by setting `mod.INITIAL_ENV` after import instead of staging a fake environ before it. Keys under test are cleared explicitly, so an assertion cannot pass on whatever the developer's shell exported. The PATH case is asserted as "unchanged" rather than "equals a fixture value", which is the actual claim. I have not proven this caused the timeout — a slow runner is equally consistent with the evidence, and the job has been re-run. The hazard is real either way and costs nothing to remove. 40 passed; 118 passed alongside the ent#127 suite under all three CI seeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Rebased onto Verified wider than the conflicted files this time, which is the lesson from #1976 earlier today: git auto-merged two Unblocking this also unblocks #2023 — the |
2a80654 to
1747b04
Compare
vybe
left a comment
There was a problem hiding this comment.
The core mechanism is right and the argument for it is good — build_execution_env rebuilding from dict(INITIAL_ENV) → .env → overrides → extra genuinely gives the deletion property, all five spawn sites are converted, and sync_process_env's delete phase correctly gates on _MIRRORED_KEYS so baseline-only keys are restored rather than popped. Overrides-after-file for #1089 is the right ordering.
Two things block.
1. env_drift names credential variables on the one credential endpoint deliberately left on the looser gate — and this repo documents why that was safe.
agent_server/routers/credentials.py adds env_drift (one entry per .env key, by name) to GET /api/credentials/status. Its backend proxy is get_authorized_agent_by_name with no reject_agent_principal, while every sibling (/credential-requirements, /inject, /export, /import) is owner + human-only.
I verified the rationale is written out in src/backend/routers/credentials.py right above the sibling route:
"What it would disclose is a targeting map, not a status light: it names
STRIPE_SECRET_KEYper agent and says which are populated … A prompt-injected agent gets it with one curl."
"/credentials/statusgets away with the read gate because it returns a COUNT and names zero variables."
That last sentence is exactly what this PR falsifies. Reachable by any user the agent is merely shared with, and by any agent-scoped MCP key — get_authorized_agent_by_name resolves it to the owner carrying the owner's role, and the MCP tool get_credential_status does a full JSON.stringify pass-through, so on a default admin-owned install that is the whole fleet. Arguably wider than the ent#127 checklist it is now looser than: that names declared requirements, env_drift names what is actually in .env, including undeclared variables.
Please move the drift report behind get_owned_agent_by_name + reject_agent_principal (own route, or fold into /credential-requirements) and update that comment either way. A diagnostic added by a security fix shouldn't widen the surface the fix is about.
2. /update now delivers quote-bearing values corrupted.
Pre-PR, /update mirrored the raw submitted value into os.environ and the child inherited it. Post-PR it round-trips through the writer's escaping and back through a parser that strips but does not unescape:
raw 'pa"ss' -> file 'K="pa\"ss"' -> delivered 'pa\"ss' CORRUPTED
raw "'quoted'" -> ... -> delivered 'quoted' quotes eaten
raw 'tail"' -> ... -> delivered 'tail\' CORRUPTED
The "byte-faithful, don't move the ent#127 predicate under a security fix" reasoning is sound about the predicate, but not about delivered values, and /update was the one path that never had this loss. It is a silent auth failure of exactly the class #1999 is about. Either fix writer + parser + _env_pairs together here, or state the exposure and land it as an immediate follow-up. (Note #2030 moves the writer/reader pair — worth coordinating rather than landing these independently.)
Non-blocking, but worth addressing:
- The wiring is untested. The static guard covers the 5 spawn sites; nothing covers the 4 new router call sites. Deleting the two
set_runtime_override(...)lines the AC table cites as "what keeps #1089 working" leaves the suite green. - The static guard walks one tree (
agent_server/services/only).routers/brain_orb.pyalready spawns a subprocess, and a futurerouters/spawn site with{**os.environ}wouldn't be caught — the #1965 lesson ("a guard that walks only one of the two trees is not a guard") one directory over. - Deleting
test_decode_divergence_is_deliberatehides a behaviour change: the agent server went from raising on a non-UTF-8.envto silently substituting U+FFFD into a credential value. PROTECTED_KEYSomits the Node equivalents —NODE_OPTIONS=--require …isLD_PRELOADfor Node, and both runtimes are Node processes. Same forGIT_SSH_COMMAND/GIT_CONFIG_*.- New torn-read window:
.envis written non-atomically and now read on the spawn path from a worker thread. Fail-safe (degrades to baseline), but tmp +os.replacecloses it — the codebase already uses that idiom forsync-state.json. - Stale PR body: the test-plan bullet describing a "one-matched-pair unquote" is the opposite of what ships, and the test count is 40, not 36.
Also needs a rebase — conflicts on learnings.md and tests/registry.json only.
Mechanical conflict resolution only: - tests/registry.json: rebuilt from index stages (dev entries deduped, PR entry kept) - docs/memory/*.md: union merge of two append-only additions No code changes.
|
Re-validated today as part of a merge sweep. Holding — head unchanged since my review; the only new commit is a Both blocking items confirmed still present:
Core mechanism is still right and I want it in; it's the drift-report gate and the delivery corruption that block. |
…the /update loss
Three review items.
1. `env_drift` named credential VARIABLES on the one credential endpoint
deliberately left on the looser read gate — and this repo documents why that
was safe: '/credentials/status gets away with the read gate because it
returns a COUNT and names zero variables'. This PR falsified that sentence.
Reachable by any user the agent is merely shared with, and by any
agent-scoped MCP key (which resolves to the owner carrying the owner's
role), with `get_credential_status` passing the body straight through — so
on a default admin-owned install, the whole fleet. Arguably wider than the
ent#127 checklist it was looser than: that names DECLARED requirements,
this named what is actually in .env, undeclared variables included.
The drift now has its own route, `GET /agents/{name}/credentials/env-drift`,
behind `get_owned_agent_by_name` + `reject_agent_principal` — the same gate
as /credential-requirements, /inject, /export, /import. The shared status
route strips the field, so the comment above it is true again, and it now
says where the drift went and why.
2. PROTECTED_KEYS omitted the Node and git equivalents. Every runtime here is a
Node process, so `NODE_OPTIONS=--require ...` is LD_PRELOAD by another name;
`GIT_SSH_COMMAND`/`GIT_EXTERNAL_DIFF`/`GIT_CONFIG_*` are executed by git on
the agent's next fetch or push. Added.
3. The /update value corruption is pinned as a strict xfail rather than left in
prose. It is real: the path round-trips through the writer's escaping and
back through a parser that strips but does not unescape, so a quote-bearing
credential is delivered corrupted, and /update was the one path that never
had this loss. Fixing the encoding HERE would move the ent#127 predicate
under a security fix and collide head-on with #2030, which does exactly that
and is stacked on this branch. Composed locally to prove the follow-up
closes it: with #2030 merged the marker flips to XPASS(strict) — loud, and
telling whoever lands that PR to drop the marker.
Also the two guard gaps: the spawn-site guard walked only `services/` while
`routers/brain_orb.py` already spawns a subprocess (the #1965 lesson one
directory over) — it now walks both trees; and the two `set_runtime_override`
calls the AC table cites as 'what keeps #1089 working' had no coverage at all
(deleting both left the suite green) — an AST test now pins both keys and the
force-unset form.
41 passed, 1 xfailed on this branch; composed with #2030: the xfail flips.
Related to #1999
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # tests/registry.json
|
@vybe — both blocking items addressed. 1.
2. Fixing the encoding here would move the ent#127 predicate under a security fix — the exact thing this PR's own reasoning argues against — and collide head-on with #2030, which does it properly and is stacked on this branch. So it's now a That way the loss can't be forgotten between the two merges, and whoever lands #2030 is told to retire it. Non-blocking items, done:
Not done, deliberately — each is a behaviour change I'd rather not fold into a security fix: restoring Merged latest |
vybe
left a comment
There was a problem hiding this comment.
Re-reviewed. Both blocking items addressed the way I'd hoped.
1. env_drift moved to its own owner-gated route — GET /credentials/env-drift behind get_owned_agent_by_name + reject_agent_principal, and /credentials/status strips the field so the repo's own written rationale ("gets away with the read gate because it returns a COUNT and names zero variables") is true again. A diagnostic added by a security fix widening the surface that fix is about was the whole objection; this closes it.
2. /update corruption pinned as xfail(strict=True) rather than fixed here — correct call. Folding the ent#127 predicate under a security fix is the thing this PR's own reasoning argues against, and it would collide with #2030. The marker makes the loss impossible to forget between merges.
PROTECTED_KEYS gaining the Node and git equivalents is the right generalization — NODE_OPTIONS --require is LD_PRELOAD under another name, and GIT_SSH_COMMAND executes on the next fetch. The static guard walking routers/ as well as services/ is the #1965 lesson applied one directory over.
Merging first in the chain. #2030 lands next and retires the xfail; #2014 after that, per its own merge-order note.
# Conflicts: # tests/registry.json
… overlap Five conflicts; the interesting ones are the #2010 squash landing under this stacked branch and #2014 deleting the endpoint this PR patched: - routers/credentials.py: took dev — #2014 removed the unreachable /api/credentials/update endpoint, and this PR's edits there (the format_env_line call + its ValueError->400 mapping) die with it. The file is byte-identical to dev; the newline refusal stays pinned at function level for BOTH writers (TestNewlinesAreRefusedNotEscaped). - services/execution_env.py + test_1999_execution_env.py (add/add): re-merged three-way using the branch's own #2010 tip (1747b04) as base, composing dev's post-review #2010 revisions (NODE_*/GIT_* PROTECTED_KEYS, the routers/-walking spawn-site guard) with this PR's #2023 additions (format_env_line / unquote_env_value, the quirk-table retargets). - test_1999::test_update_delivers_quote_bearing_values_intact: dev's xfail(strict) whose own reason says it flips to XPASS when #2023 lands — converted to a plain regression test pinning that .env files written by the OLD quote-only escaping (already on deployed agents' disks) still decode intact under the new reader. - test_ent127_predicate.py: kept this branch's semantic anchor (build_execution_env still parses .env) over dev's byte-faithful-line anchor — #2023 deliberately made that exact line false. - tests/registry.json: rebuilt from stages — dev's 166 entries plus this PR's test_2023 entry (both sides' test_1999 entries were identical). Verified locally: 627 passed (test_2023 / test_1999 / test_ent127 plus the credential + PAT-propagation set, incl. #2014's dead-endpoint guard test_2008_dead_credentials_update). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) * fix(agent-server): rebuild the execution env per spawn so a removed .env key stops applying (#1999) The credential endpoints mirrored every submitted key into the long-lived agent-server process's `os.environ`, and all five runtime spawn sites passed `env={**os.environ, ...}`. The `.env` file was never read back into that process, so the file an operator inspects and the environment executions receive were two independent channels kept in sync by one code path, in one direction, for present keys only. A key removed from `.env` by any non-mirroring write path — SSH, `docker exec`, or an agent editing its own `.env` — therefore kept reaching every subsequently-spawned execution until the container restarted, and was invisible to `/proc/<pid>/environ` (an exec-time snapshot) and to `docker exec` (the container baseline, not the mutated process env). Credential revocation silently failed with no inspection route that could reveal it. `services/execution_env.py` makes the execution environment a pure function of three inspectable inputs, rebuilt per spawn: the container baseline captured at import, `.env` parsed fresh (authoritative for credentials), and a small named overrides dict. Overrides are applied after the file, not before as the issue sketched — `/api/credentials/reload-token` deliberately never writes `.env`, so file-last would let a stale token in the file beat an explicit rotation and re-open #1089 from the other side. `None` means force-unset, which a dict merge cannot express and `remove_api_key` needs. The process mirror stays, because in-process readers (error classifier, AGENT_RUNTIME, the sanitizer's redaction set) depend on it, but gains a delete phase that restores the container baseline rather than popping blind, and only ever undoes keys it itself wrote. `.env` may no longer set loader/exec-redirecting names (PATH, LD_PRELOAD, BASH_ENV, …): it is agent-writable and is now read at every spawn, a wider trigger than the old backend-only mirror. `GET /api/credentials/status` gains a per-key `env_drift` report — names only, never values — so this class is one call to diagnose instead of a multi-hour forensic hunt. Closes #1999 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent-server): keep .env parsing byte-faithful; re-point the ent#127 anchor CI caught a real regression, not a stale anchor: `test_ent127_predicate::test_source_anchor_still_present` failed on all three seeds. The ent#127 "is this credential set" predicate (`credential_requirements_service._env_pairs`) is *defined* as agreement with the agent's export loop, and its source is spliced into an in-container probe — and this branch had replaced that loop with a parser that also improved the parsing: one matched quote pair instead of peeling every layer, unescaping what the writer escapes, honouring `export `, and a key-name filter. Each of those is a real improvement, and every one of them silently moves that predicate. `export KEY=v` would have flipped from missing to set; `KEY=""""` from empty to non-empty; `K-E-Y` would have stopped reaching the child at all — a behaviour narrowing inside a fix whose entire subject is that credentials must keep behaving predictably. So the parsing is reverted to byte-faithful, and this branch keeps only the lifecycle change it is actually about: the execution env is rebuilt per spawn from the file, and the mirror gained a delete phase. `_env_pairs` needs no change, so ent#127's predicate and its spliced probe are untouched. What did move is the anchor: the loop now lives in `execution_env.parse_env_file` rather than inside `routers/credentials.py`. Re-pointed there, plus a new guard that fails if the set-only mirror ever comes back — the shape that caused #1999 in the first place. Improving the parse is worth doing; it belongs in its own change, with ent#127's parity test as the gate rather than the casualty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(1999): stop swapping the process-global os.environ in the fixture CI's seed-99999 shard hit the job's 25-minute timeout while the other two seeds finished in ~10. The suite was still progressing (96% when the axe fell), so it was slow rather than wedged — and the one seed-dependent hazard this branch adds is right here. The fixture replaced `os.environ` with a plain dict for the duration of each of 36 tests. That is hermetic for the test and hostile to the rest of the process: the mapping stops writing through to the C environment, and any thread still alive from another test — an APScheduler tick, an event-bus dispatcher — sees an environment with no PATH or HOME for that window. Whether such a thread overlaps these tests is decided by pytest-randomly's seed, which is the shape of a failure that appears under one seed and not the other two. Now: an autouse fixture snapshots and restores the CONTENTS of the real mapping, and the baseline is injected by setting `mod.INITIAL_ENV` after import instead of staging a fake environ before it. Keys under test are cleared explicitly, so an assertion cannot pass on whatever the developer's shell exported. The PATH case is asserted as "unchanged" rather than "equals a fixture value", which is the actual claim. I have not proven this caused the timeout — a slow runner is equally consistent with the evidence, and the job has been re-run. The hazard is real either way and costs nothing to remove. 40 passed; 118 passed alongside the ent#127 suite under all three CI seeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(credentials): make the .env quote encoding reversible (#2023) The writer escaped an embedded double quote; the reader never unescaped it. Any credential containing `"` was injected as one string and read back as another — `a"b` written as `KEY="a\"b"` and read as `a\"b` — so the agent authenticated with a value the operator never supplied. It presents as a bad credential, which is the worst possible disguise for a parsing bug. Not a PAT problem (PATs are `[A-Za-z0-9_]`), which is why it was split out of #2017: it affects every credential written through this path. Four things move together, which is why this waited for #2010: - the WRITER escapes the escape character first. Escaping only `"` left the encoding undecodable, not merely untidy: a value ending in `\` produced `KEY="a\"`, whose closing quote reads as escaped. - the READER gains `unquote_env_value` — one matched pair stripped, the encoding reversed in a single scan. Two sequential replaces mishandle an escaped backslash that precedes an escaped quote. - `_env_pairs`, the ent#127 predicate, carries an inlined mirror. Its SOURCE is spliced into an in-container probe, so an import would NameError there. - `test_ent127_predicate`'s parity fixtures now run against the REAL reader, loaded by path, instead of a hand-written replica. A replica is only as honest as the last person to sync it, and the old guard could prove the original existed but never that the copy still matched it. Mutation testing found two holes in my own tests before they shipped: - the first draft reimplemented the writer inside the test file, so deleting the real writer's backslash-escaping broke nothing. The encode half is now `execution_env.format_env_line`, sitting beside its inverse — two halves of one encoding belong in one file — and the tests call it. - quote-escaping survived every round-trip test, because positional stripping decodes `KEY="a"b"` correctly regardless. The escaping still matters for consumers that are not this parser: a shell sourcing `.env` reads `ab`. That is now pinned line-by-line rather than only through the round trip. #1999's deliberately byte-faithful quirk table fired on three rows and was retargeted at the new contract — which is precisely what it was pinned for: the change is visible and argued instead of silent. 46 new tests; 872 green across the affected surface. Closes #2023 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(2023): migrate the fourth decoder copy, align the second writer, refuse newlines Three gaps the first version left, all in the same class the PR argues against. 1. `credential_sanitizer` — the module that builds the REDACTION set — held a fourth copy of `.strip('"').strip("'")`. Before this PR the two decoders agreed (both corrupted identically), so redaction worked. Making the execution path correct without moving this one means the redaction set holds the ESCAPED form while the execution receives the UNESCAPED one, and `sanitize_text()` stops redacting a live credential from logs — a security regression caused by a correctness fix. Reachability confirmed: startup.sh never sources .env, so the os.environ branch of `_load_credential_values()` contributes nothing and the file branch is the only source. It now uses the shared `unquote_env_value`, resolved by PATH: this file is loaded three ways (package, flat, standalone-by-path in tests) and only the path is the same in all three — `services.execution_env` resolves to the BACKEND package inside a test process, which is a different module. Falls back to positional stripping with a warning if the sibling can't be loaded, so redaction degrades rather than vanishing. 2. `github_pat_propagation_service._format_pat_line` is a second writer of the same file whose docstring asserted parity with the agent's writer — a parity escaping only `"` never had; runs of two or more backslashes decoded wrong. It now escapes the escape character first. Backend code cannot import the agent-side module (different image), so the contract is held by a parity test over a shared corpus. 3. `format_env_line` REFUSES `\n`/`\r` rather than escaping them. The reader is line-oriented, so a newline-bearing value defines a NEW KEY in `build_execution_env` — and PROTECTED_KEYS refuses LD_PRELOAD but not ANTHROPIC_API_KEY / GITHUB_PAT / CLAUDE_CODE_OAUTH_TOKEN. Escaping would make the encoding reversible on paper while every other .env consumer (a shell sourcing the file, the ent#127 probe) still reads two lines. /update maps it to a 400 naming the key. Also: PARITY_FIXTURES had no backslash fixture at all — seven added, covering escaped quote, escaped backslash, runs, trailing, and the single-quoted and unquoted shapes. Mutation-verified: sanitizer reverted to the old decoder -> 2 failed pat writer escaping only the quote -> 8 failed as shipped -> 74 passed (file), 1264 passed (related) Related to #2023 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trinity-ability <trinity-ability@users.noreply.github.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
`_patch_env_github_pat` passed the formatted `.env` line to `re.sub` as the
replacement STRING, and `re.sub` parses that for escapes. A token carrying a
backslash was therefore read as a group reference:
ghp_a\g<1>b -> re.error: invalid group reference 1 at position 20
ghp_back\slash -> re.error: bad escape \s at position 20
The blast radius was bounded and honest — `_propagate_to_agent` catches only
httpx errors, so this escaped to the `return_exceptions=True` gather and was
recorded as that agent's `failed` with the message attached, and the rotation
continued. But the trigger is the TOKEN, not the agent, so it failed for every
agent in the fleet and told the operator `bad escape \s` rather than anything
about the token they had just pasted.
A callable replacement is inserted verbatim, which is what a credential always
needs.
Not doing PAT-shape validation at the settings boundary, the other option the
issue offers: GitHub ships at least six token formats (ghp_/gho_/ghs_/ghu_/
ghr_/github_pat_ plus legacy 40-hex), and a regex tight enough to catch a
stray backslash is tight enough to reject a format that ships next year.
Rejecting a valid token is worse than writing an invalid one through, and the
AC's requirement is "never as re.error".
The `.env` quote-escaping half of #2017 is split to #2023 rather than bundled:
it is not a PAT problem (PATs are [A-Za-z0-9_]) but affects every credential on
that path, and fixing it means moving writer, reader, the ent#127 predicate and
its spliced in-container probe in one commit — on top of #2010, which rewrites
that exact reader. Bundling it here would conflict directly with an open PR.
29 tests over seven hostile token shapes; mutation-verified — restoring the
string replacement fails 13 of them.
Closes #2017
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
.envand the environment executions actually receive were two independent channels. The credential endpoints mirrored every submitted key into the long-lived agent-server process'sos.environ; all five spawn sites passedenv={**os.environ, ...}; the file was never read back into that process. One code path, one direction, present keys only — no delete phase.So a key removed from
.envby any non-mirroring write path (SSH,docker exec, or an agent editing its own.env) kept reaching every subsequently-spawned execution until container restart, and was invisible to/proc/<pid>/environ(an exec-time snapshot) and todocker exec(the container baseline, not the mutated process env). Credential revocation silently failed, and every inspection route confirmed the wrong answer.The fix
docker/base-image/agent_server/services/execution_env.py— the execution environment becomes a pure function of three inspectable inputs, rebuilt per spawn instead of accumulated in process state:A key that is in neither the baseline nor
.envnow has no path into a spawned process, so the class cannot recur.Two deliberate divergences from the issue's sketch
Overrides are applied AFTER the file, not before.
/api/credentials/reload-tokenrotates the subscription token without writing.env(by design — "the subscription token is not a .env credential"). With file-last precedence, a staleCLAUDE_CODE_OAUTH_TOKENthat ever reaches.env— a hand edit, a credential export that captured it — would beat the rotation and re-open #1089 from the other side. An explicit hot-reload is the most recent and most specific signal; it wins.Nonemeans force-unset, which a plain dict merge cannot express andremove_api_keyrequires.The process mirror stays — deleting it would break the in-process readers the issue names (error classifier's
ANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKENprobes,AGENT_RUNTIME,GOOGLE_API_KEY, and the sanitizer's redaction set). It gains the missing delete phase: a key this process mirrored that the new.envno longer contains is restored to its container-baseline value, or popped when it has none. Only keys the mirror itself wrote are eligible, so a baseline-only key is never deleted.One behaviour narrowing, on purpose
.envmay no longer setPATH,LD_PRELOAD,LD_LIBRARY_PATH,LD_AUDIT,PYTHONHOME,PYTHONPATH,PYTHONSTARTUP,BASH_ENV,ENV,IFS. These carry no credentials — they redirect what the child executes or loads..envis agent-writable and is now read at every spawn, a wider trigger than the old backend-only mirror, so a prompt-injected workspace could otherwise repoint the runtime binary. Ignored with a warning, never fatal.Diagnosability
GET /api/credentials/statusgainsenv_drift:{key, in_file, in_process_env, equal}per key — names only, never values. The issue took hours to root-cause because every available inspection route read a different channel than the enforcement path; this is the route that compares them.Acceptance criteria
build_execution_envparses.envper spawn ·test_key_deleted_out_of_band_does_not_reach_the_next_execution.envauthoritative for credential valuestest_env_file_is_authoritative_over_the_baseline/reload-tokenstill rotates for the next execution (#1089)set_runtime_override, applied after the file · 3 precedence tests incl. the stale-token-in-file caseEXECUTION_TAG_NAMEstill reaches every process (#407)extraapplied last ·test_execution_tag_cannot_be_displaced_by_the_env_fileAGENT_RUNTIME/ANTHROPIC_API_KEY/GOOGLE_API_KEYstill resolve in-processtest_a_baseline_key_is_restored_not_deleted/injectmirror removes keys absent from the new.envsync_process_envdelete phase ·test_reinjecting_a_cleaned_env_clears_the_process_mirrorTestRemovedKeyStopsReachingExecutionstest_every_spawn_site_builds_its_env_through_the_helperTest plan
tests/unit/test_1999_execution_env.py— 36 tests: the issue's reproduction, baseline restore, precedence (incl. both feat: credential rotation via hot-reload, not container recreate #1089 no-regression directions), tolerant.envparsing, unreadable/oversized files degrading to the baseline rather than raising on the spawn path, protected keys, the name-only drift report, and a static guard against a sixth spawn site reintroducing{**os.environ}-k "credential or agent_server or 1089 or subscription or codex or gemini or claude_code or 407 or orphan").envparser matches the writer's own escaping —.strip('"')eats a legitimately trailing quote, so it is a one-matched-pair unquote insteadNot included
The issue's amplifiers are noted but out of scope: the boot re-push (
inject_assigned_credentials) and the fleet-wide PAT propagation still rewrite.envfrom the backend's stored archive. That is the intended source of truth and the result is now visible in the file, so it is no longer a ghost — but it does mean a hand-edit to.envis still overwritten on next boot. #1967 is open on that same propagation path.Closes #1999