Skip to content

fix(agent-server): rebuild the execution env per spawn so a removed .env key stops applying (#1999) - #2010

Merged
vybe merged 7 commits into
devfrom
fix/1999-env-ghost
Aug 10, 2026
Merged

fix(agent-server): rebuild the execution env per spawn so a removed .env key stops applying (#1999)#2010
vybe merged 7 commits into
devfrom
fix/1999-env-ghost

Conversation

@dolho

@dolho dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

.env and the environment executions actually receive were two independent channels. The credential endpoints mirrored every submitted key into the long-lived agent-server process's os.environ; all five spawn sites passed env={**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 .env by 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 to docker 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:

env = build_execution_env({EXECUTION_TAG_NAME: execution_id})
#   INITIAL_ENV        container baseline captured at import — what `docker exec` shows
#   .env               parsed fresh at every spawn — authoritative for credentials
#   RUNTIME_OVERRIDES  values that are deliberately not .env credentials (#1089)
#   extra              last, so EXECUTION_TAG_NAME (#407) can never be displaced

A key that is in neither the baseline nor .env now 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-token rotates the subscription token without writing .env (by design — "the subscription token is not a .env credential"). With file-last precedence, a stale CLAUDE_CODE_OAUTH_TOKEN that 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. None means force-unset, which a plain dict merge cannot express and remove_api_key requires.

The process mirror stays — deleting it would break the in-process readers the issue names (error classifier's ANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKEN probes, 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 .env no 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

.env may no longer set PATH, 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. .env is 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/status gains env_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

AC Where
Removed key stops reaching executions (any write path) build_execution_env parses .env per spawn · test_key_deleted_out_of_band_does_not_reach_the_next_execution
.env authoritative for credential values file layer applied over the baseline · test_env_file_is_authoritative_over_the_baseline
/reload-token still rotates for the next execution (#1089) set_runtime_override, applied after the file · 3 precedence tests incl. the stale-token-in-file case
EXECUTION_TAG_NAME still reaches every process (#407) extra applied last · test_execution_tag_cannot_be_displaced_by_the_env_file
AGENT_RUNTIME / ANTHROPIC_API_KEY / GOOGLE_API_KEY still resolve in-process mirror retained; baseline restored on delete · test_a_baseline_key_is_restored_not_deleted
/inject mirror removes keys absent from the new .env sync_process_env delete phase · test_reinjecting_a_cleaned_env_clears_the_process_mirror
Regression test for the reported scenario TestRemovedKeyStopsReachingExecutions
Uniform across Claude Code, Codex, Gemini all five spawn sites wired · static guard test_every_spawn_site_builds_its_env_through_the_helper

Test plan

  • tests/unit/test_1999_execution_env.py36 tests: the issue's reproduction, baseline restore, precedence (incl. both feat: credential rotation via hot-reload, not container recreate #1089 no-regression directions), tolerant .env parsing, 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}
  • Mutation-verified — each of (a) reverting one spawn site, (b) removing the delete phase, (c) applying overrides before the file fails exactly its own tests (1, 2, 3 failures respectively), never silently passing
  • 871 adjacent tests green (-k "credential or agent_server or 1089 or subscription or codex or gemini or claude_code or 407 or orphan")
  • .env parser matches the writer's own escaping — .strip('"') eats a legitimately trailing quote, so it is a one-matched-pair unquote instead

Not 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 .env from 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 .env is still overwritten on next boot. #1967 is open on that same propagation path.

Closes #1999

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

dolho and others added 3 commits August 5, 2026 15:33
…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>
@dolho

dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto dev — it had gone conflicting after #1901 and others landed. Only tests/registry.json and docs/memory/learnings.md conflicted, both append-only on each side; unioned with ours last and the registry re-serialized from parsed JSON (never line-spliced, so the separating comma can't be lost).

Verified wider than the conflicted files this time, which is the lesson from #1976 earlier today: git auto-merged two ExecutionOrigin class definitions there without reporting a conflict, and my post-rebase test filter was narrower than the blast radius, so 129 tests passed over a broken file. So: swept every changed .py for duplicate top-level definitions (clean), confirmed the registry diff has zero deletions, and ran 1224 tests across 1999 / ent127 / credential / agent_server / execution / 1089 / codex / gemini.

Unblocking this also unblocks #2023 — the .env quote-escaping asymmetry can only land on top of the parser this PR introduces.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_KEY per agent and says which are populated … A prompt-injected agent gets it with one curl."
"/credentials/status gets 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 keyget_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.py already spawns a subprocess, and a future routers/ 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_deliberate hides a behaviour change: the agent server went from raising on a non-UTF-8 .env to silently substituting U+FFFD into a credential value.
  • PROTECTED_KEYS omits the Node equivalents — NODE_OPTIONS=--require … is LD_PRELOAD for Node, and both runtimes are Node processes. Same for GIT_SSH_COMMAND/GIT_CONFIG_*.
  • New torn-read window: .env is written non-atomically and now read on the spawn path from a worker thread. Fail-safe (degrades to baseline), but tmp + os.replace closes it — the codebase already uses that idiom for sync-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.
@vybe

vybe commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Re-validated today as part of a merge sweep. Holding — head unchanged since my review; the only new commit is a Merge origin/dev.

Both blocking items confirmed still present:

  1. env_drift on the loose gate. "env_drift": env_drift_report(env_file) is still wired into GET /api/credentials/status, whose backend proxy is get_authorized_agent_by_name with no reject_agent_principal. That endpoint's own in-repo comment — "/credentials/status gets away with the read gate because it returns a COUNT and names zero variables" — is exactly what this falsifies. Worth noting fix(security): admin gates reject agent-scoped keys #1890 landed today, so require_admin/assert_admin now reject agent principals fleet-wide; that does not cover this route, which is on the per-agent access gate, so the finding is unchanged.
  2. /update delivers quote-bearing values corrupted — still round-trips through a writer that escapes and a parser that strips without unescaping.

Core mechanism is still right and I want it in; it's the drift-report gate and the delivery corruption that block.

dolho and others added 2 commits August 10, 2026 11:33
…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>
@dolho

dolho commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@vybe — both blocking items addressed.

1. env_drift moved off the loose gate. You're right that this falsified the sentence the repo has written down ("/credentials/status gets away with the read gate because it returns a COUNT and names zero variables"), and that it's reachable by a merely-shared user and by any agent-scoped MCP key resolving to the owner's role — with get_credential_status doing a JSON.stringify pass-through, so the whole fleet on a default admin-owned install.

  • new GET /api/agents/{name}/credentials/env-drift, behind get_owned_agent_by_name + reject_agent_principal — the same gate as /credential-requirements, /inject, /export, /import;
  • /credentials/status strips the field, so the existing rationale comment is true again, and it now records where the drift went and why. A diagnostic added by a security fix shouldn't widen the surface the fix is about — agreed.

2. /update value corruption — pinned as a strict xfail rather than left as a paragraph.

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 strict=True xfail naming the exposure, and I verified the follow-up actually closes it by composing the two locally:

this branch alone:      41 passed, 1 xfailed        <- corruption pinned, visible
merged with #2030:      [XPASS(strict)] ...         <- flips loud; drop the marker there

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:

  • PROTECTED_KEYS gained the Node and git equivalents — NODE_OPTIONS/NODE_PATH/NODE_REPL_EXTERNAL_MODULE (every runtime here is a Node process, so --require is LD_PRELOAD under another name) and GIT_SSH_COMMAND/GIT_SSH/GIT_EXTERNAL_DIFF/GIT_PAGER/GIT_EDITOR/GIT_CONFIG_*, which git executes on the agent's next fetch or push.
  • The static guard walks both trees now (services/ and routers/) — routers/brain_orb.py already spawns a subprocess, so this was the bug(security): agent server still parses author-controlled YAML with bare safe_load — outside the ent#314 sweep #1965 lesson one directory over.
  • The router wiring is tested. Deleting both set_runtime_override(...) calls the AC table cites left the suite green; an AST test now pins both keys and the force-unset (value=None) form.

Not done, deliberately — each is a behaviour change I'd rather not fold into a security fix: restoring test_decode_divergence_is_deliberate (the U+FFFD substitution is a real change and belongs with the parser work in #2030), the atomic .env write (tmp + os.replace), and the stale PR-body bullets — I'll refresh the body separately.

Merged latest dev; the sequencing you asked for (#2010#2030#2024) is unchanged and the xfail now enforces the first hop.

@dolho
dolho requested a review from vybe August 10, 2026 08:54

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed. Both blocking items addressed the way I'd hoped.

1. env_drift moved to its own owner-gated routeGET /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
@vybe
vybe enabled auto-merge (squash) August 10, 2026 16:24
@vybe
vybe merged commit f744f34 into dev Aug 10, 2026
23 checks passed
vybe pushed a commit that referenced this pull request Aug 11, 2026
… 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>
vybe pushed a commit that referenced this pull request Aug 11, 2026
)

* 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>
dolho added a commit that referenced this pull request Aug 11, 2026
`_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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants