Skip to content

fix(runstore): keep the host session out of a detached run's env (#1082) - #1088

Merged
Birfy merged 6 commits into
mainfrom
agent/1082-surface-write-dirs
Nov 15, 2026
Merged

Birfy merged 6 commits into
mainfrom
agent/1082-surface-write-dirs

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

Partial: #1082 — row 3. Row 1 (the CLI write allowlist) is #1087, merged as f5e93335; row 2 (per-Ticket budget caps) stays open on the issue.

What changed

runstore.launch built the detached run's environment from all of os.environ, so the worker's own session leaked into the run: its DSH_* markers, its CLAUDE*/CODEX* host markers, and any MCP_* server configuration that would hand the run a second set of tools. The run is our own process, so the candidate allowlist (runners._child_env) cannot be reused — it would drop the API keys and PATH the run needs.

  • agentdescent/surface_common.py: the package's one host-session denylist SESSION_MARKERS, is_session_marker, the DETACHED_KEEP_VARS keep set, and scrub_host_env(environ=None).
  • agentdescent/agents.py: imports the list from surface_common instead of carrying a second copy.
  • agentdescent/runstore.py: launch builds the child env with scrub_host_env({**os.environ, ...}), so env= cannot put the session back.
  • tests/test_host_env_scrub.py (new): drop, keep, one-list, four-host-root, isolation and launch rows.
  • docs/dsh-super-agent.md §5 and changelog.d/1082-host-env-scrub.md.

No model or network call. cli.py is untouched: the apply/show --diff write allowlist is #1087's row, and the budget caps are row 2.

Review findings

  1. The deny list deleted host config dirs the run reads. integrations._host_root reads four variables — DSH_HOME, CODEX_HOME, XDG_CONFIG_HOME (for _opencode_root) and CLAUDE_CONFIG_DIR — so DETACHED_KEEP_VARS keeps all four by exact name. Everything else in SESSION_MARKERS still goes, OPENCODE_CONFIG_DIR and OPENCODE_CONFIG_CONTENT included. The measurement is under "Verification at the tip": all four survive with their values, the four roots resolve against them, and the session markers are gone.
  2. Second, divergent list. SESSION_MARKERS lives in surface_common and agents imports it; a row asserts the two names are the same object, and the parametrised row walks the tuple, so a marker added for a new host is covered the day it is added.

Red at base c94e9d67

Fresh tree at the base this PR targets, with only tests/test_host_env_scrub.py copied in:

tests/test_host_env_scrub.py:16: in <module>
    from agentdescent.surface_common import scrub_host_env
E   ImportError: cannot import name 'scrub_host_env' from 'agentdescent.surface_common'
ERROR tests/test_host_env_scrub.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 2.10s

Red before the keep sets

At c5f021e9, with no keep set: CLAUDE_CONFIG_DIR survives: False, CODEX_HOME survives: False, DSH_HOME survives: False, XDG_CONFIG_HOME survives: False, and _opencode_root returned <home>/.config/opencode while XDG_CONFIG_HOME pointed at <home>/.config-work.

At 50bde3c2, with the keep set holding three and the four-variable rows in the tree: pytest -q tests/test_host_env_scrub.py -> 4 failed, 18 passed, every failure KeyError: 'XDG_CONFIG_HOME'.

Tip SHA: 85b2974

Commits: b5b66bf8 (the original scrub), 9766bad1 (forward-merge onto c94e9d67), c5f021e9 (one list, and launch re-scrubs the merged env), 50bde3c2 (keep the three config dirs), 85b2974 (keep XDG_CONFIG_HOME, isolation rows asserted by value). Fast-forward only: no force, no rebase, no merge-up.

Verification at the tip

Ran:

  • probe over the review variables -> CLAUDE_CONFIG_DIR, CODEX_HOME, DSH_HOME, XDG_CONFIG_HOME survive with their values; CLAUDECODE, CODEX_SESSION_ID, DSH_SESSION_ID, MCP_SERVERS, OPENAI_AGENT_ID dropped
  • integrations._host_root re-resolved with the scrubbed env in place -> claude <home>/.claude-work, codex <home>/.codex-work, dsh <home>/.dsh-work, opencode <home>/.config-work/opencode
  • pytest -q tests/test_host_env_scrub.py -> pass (22 passed)
  • focused set (test_host_env_scrub, test_runstore_cli, test_runstore_tilde, test_cli_surface_p1, test_mcp_write_confinement) -> pass (102 passed)
  • pytest -q tests/test_plugin_runner.py -> pass (15 passed)
  • python -m tools.gen_api_docs --check -> pass (docs/api.md is up to date)
  • python -m tools.changelog_fragments check -> pass (40 fragments OK)
  • mkdocs build --strict -> exit 0 (13.47 s)
  • full pytest -q at this tip: the 492 files from ls tests/test_*.py | sort, split into 8 even chunks and run in parallel as pytest -q <chunk files> with an AGENTDESCENT_HOME per chunk -> 7418 passed, 12 failed, 48 skipped, 10 errors. The red set is the environment set: test_era_srbench 6, test_genesis_example 2, test_sandbox_container 2 failed + 10 errors (the docker image cannot be pulled on this host), test_wheel_smoke 1, and test_integrations::test_the_launcher_install_writes_ignores_a_shadowing_cwd 1, which fails identically at base c94e9d67 (a stale console script in this box's venv). None of them is in runstore.py, surface_common.py, agents.py or tests/test_host_env_scrub.py.

Not run:

  • the docker sandbox, test_era_srbench, test_genesis_example, wheel-smoke and read-only-$HOME legs -> environmental on this host

Size: 6 files, +397/-27. T3: over the 250-line cap, and risk:T3 from the controller matches.

…hed runs (#1082)

The MCP surface refused a write outside AGENTDESCENT_SURFACE_WRITE_DIRS
(#1018), but `agentdescent apply` passed no check, so a host worker that could
not write through the tool could still write through the CLI. Pass the same
allowlist check at the CLI's write-advertising paths: `apply` (run and
`--from`, dry run included) and `show --diff`'s `apply_plan`, which now carries
the refusal instead of a path the worker cannot use. Unset means unchanged CLI
behaviour; an empty value refuses every write.

`runstore.launch` also copied all of `os.environ` into the detached run, so the
worker session's `DSH_*`/`CLAUDE*`/`CODEX*`/`MCP_*` variables -- including its
MCP server config -- reached the run. Drop those prefixes with
`surface_common.scrub_host_env`; the API keys and PATH the run needs still pass
through.

Refs #1082: the per-Ticket budget-cap row is not in this change.
)

Row 3 of #1082: the run `launch` detaches is our own process, so it inherits
all of os.environ -- including the worker session's DSH_*/CLAUDE*/CODEX*/MCP_*
variables and its MCP server config. Drop those prefixes with
surface_common.scrub_host_env; the API keys and PATH the run needs still pass
through. Row 1 (the CLI write allowlist) already has its own carrier in #1087,
so this change does not touch it.

Refs #1082: the per-Ticket budget-cap row is still open.
@github-actions github-actions Bot added risk:T3 Risk tier T3 (core loop, release, process; needs Danyang's click), set by the controller agent-pr Pull request opened by an agent labels Nov 14, 2026
@HugoXK
HugoXK marked this pull request as ready for review November 15, 2026 02:04
@Birfy

Birfy commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator

Verdict: CHANGES REQUESTED

Tip SHA: 9766bad183a58988d6280165bae70b7d8f1f483d · Risk tier: T3 (path agentdescent/runstore.py)

Ran: pytest tests/test_host_env_scrub.py → 3 passed; CI green. The finding is in what the denylist removes, not in the tests.

  1. The prefix denylist drops the host config-dir variables the run itself depends on. CLAUDE* / CODEX* / DSH_* also remove CLAUDE_CONFIG_DIR, CODEX_HOME and DSH_HOME. The detached run is agentdescent itself, and it reads those: integrations/__init__.py:127-151 (_host_root) resolves each host's home from them, and a worker spec with "isolate": false (documented in integrations/SKILL.md:126,252,265 as "gives the worker the user's real setup") now gets ~/.claude / ~/.codex instead of the user's configured directory, which for a custom CLAUDE_CONFIG_DIR means the wrong (often unauthenticated) login. This also applies to every agentdescent start from a plain terminal, not only inside a host worker.
  2. Second, divergent list. agents.SESSION_MARKERS (agents.py:277-292) is already the package's list of host-session markers and is more precise (it drops OPENAI_AGENT_ and the three OPENCODE_CONFIG* names, and keeps OPENCODE_API_KEY on purpose). A new _HOST_MARKER_PREFIXES that disagrees with it will drift.

Suggested fix: derive the scrub from SESSION_MARKERS minus the home/config-dir names in WORKER_CONFIG_DIRS (or keep those explicitly), and add a test that CLAUDE_CONFIG_DIR / CODEX_HOME / DSH_HOME survive launch while CLAUDECODE / DSH_SESSION_ID / MCP_* do not.

…d env (#1082)

`scrub_host_env` carried its own four prefixes while the candidate worker
(`agents.worker_env`) dropped eleven markers, so five of the eleven crossed the
scrub: `OPENAI_AGENT_*`, `OPENCODE_CONFIG`, `OPENCODE_CONFIG_CONTENT`,
`OPENCODE_CONFIG_DIR` and `XDG_CONFIG_HOME`. The two OpenCode config carriers
are the ones measured to hand a nested process the parent's MCP servers, which
is the recursion this guard exists for.

The tuple now lives in `surface_common` (the leaf) and `agents` imports it, so
there is one list. `launch` also scrubs the merged dict: `env=` is spread last
and could put `MCP_*`/`DSH_*` back in after the scrub.

The test walks `SESSION_MARKERS` instead of naming variables, so a marker added
for a new host is covered the day it is added.
`scrub_host_env` drops `SESSION_MARKERS`, and that list contains
`CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `DSH_HOME` (the first by name, the other
two behind the `CODEX_`/`DSH_` prefixes). They name a host's *configuration*,
not its session: the detached run is this package, and
`integrations._host_root` resolves a host's home from them, so a worker spec
with `isolate: false` -- and every `agentdescent start` from a plain terminal --
was sent to `~/.claude` / `~/.codex`, which for a custom directory is the wrong,
often unauthenticated, login (#1082 review, finding 1).

`DETACHED_KEEP_VARS` holds the three names and `scrub_host_env` keeps them by
exact name; the rest of `SESSION_MARKERS` still goes. The tuple lives in
`surface_common` rather than being imported from `agents`, because this module
stays a leaf: a test binds the set to `agents.WORKER_CONFIG_DIRS` instead.
`is_session_marker` is unchanged, so an isolated worker still redirects all five
directories into its own workspace and keeps none of the user's config.

Refs #1082.
`integrations._host_root` reads four variables, not three: `DSH_HOME`,
`CODEX_HOME`, `XDG_CONFIG_HOME` (for `_opencode_root`) and `CLAUDE_CONFIG_DIR`.
`DETACHED_KEEP_VARS` held the other three, so an OpenCode host with a custom
`XDG_CONFIG_HOME` still resolved `<home>/.config/opencode` after the scrub -- the
same defect as the three already fixed (#1082 review, finding 1).

The keep set now holds all four. Isolation is unchanged: `worker_env` filters by
`is_session_marker`, not by the keep list, so an `isolate=True` worker is still
redirected to `.agentdescent-worker/<leaf>` inside its workspace, and that row
asserts where each value points; `isolate=False` keeps the host values, which is
the contract the keep set exists for. The drift row reads the `_host_root` call
sites out of `integrations`' own source and checks both directions against
`agents.WORKER_CONFIG_DIRS`, so a fifth host root or a renamed one fails it.

Refs #1082.
@HugoXK

HugoXK commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator Author

Record for the tip under review here: 85b2974c681daeafca074e01ed86f1a0e9486624.

The last verdict on this PR names 9766bad183a58988d6280165bae70b7d8f1f483d, and that tree no longer exists on this branch. What landed after it:

  • c5f021e9 — one host-session denylist shared by agents.py:38 (from .surface_common import SESSION_MARKERS, is_session_marker) and surface_common.py, with a test asserting the two names are one object, not two lists (S1).
  • 50bde3c2 — surface_common.py:255 DETACHED_KEEP_VARS, applied at :288 by filtering SESSION_MARKERS before setdefault, so the kept host config dirs cannot re-admit a session marker.
  • 85b2974c — XDG_CONFIG_HOME joins the tuple (:256). Four variables are read by integrations._host_root (:138/:142/:146/:151), not three, so the fourth would have stayed unredirected; the isolation test now asserts the destinations (tests/test_host_env_scrub.py:138 → <ws>/.agentdescent-worker/{claude,codex,dsh,xdg}) rather than the absence of the names, and pins the opposite contract for isolate=False.
  • runstore.py:686-687 scrubs the merged {**os.environ, "PYTHONPATH": ..., **(env or {})}, so a caller env= cannot put MCP_*/DSH_* back into a detached run (S2).

Both findings are fixed at 85b2974c. The tip is clean, 9 of 9 check runs successful (deploy skipped), risk:T3, 6 files +397/−27, base c94e9d67.

reviewDecision is empty on this PR and /reviews returns 0 — no review has ever been submitted here. This comment records which tree a verdict on this PR would apply to; it is not a review request, and nothing here asks anyone to approve or merge.

@HugoXK-bot

Copy link
Copy Markdown

Verifier report (advisory — not a counting PASS; .github/agent/review-policy.json counts only Birfy).

Review tip: 85b2974c681daeafca074e01ed86f1a0e9486624 (base c94e9d67, 6 files, +397/−27; one PR on agent/1082-surface-write-dirs; non-draft; mergeable_state: clean; body Tip SHA: = head).

This is the tip after Birfy's CHANGES REQUESTED at 9766bad1; both asks are in, and I measured them rather than reading them.

Ran:

  • fresh clone of refs/pull/1088/head at this sha. One tuple, not two: surface_common.py:223-240 defines SESSION_MARKERS, agents.py:38 imports it, and agents.SESSION_MARKERS is surface_common.SESSION_MARKERS → True. runstore.launch scrubs the merged dict (runstore.py:679-688), so a caller's env= cannot put a marker back.
  • my own probe at the tip, one variable per marker: all eleven marker variables are dropped, the four config roots survive unchanged (CLAUDE_CONFIG_DIR, CODEX_HOME, DSH_HOME, XDG_CONFIG_HOME), ARK_API_KEY, OPENCODE_API_KEY, PATH, PYTHONPATH and AGENTDESCENT_NESTED pass through, and DSH_SECRET_* is dropped. is_session_marker("OPENCODE_API_KEY") is False; worker_env(isolate=True) still drops and redirects all four config roots.
  • tests/test_host_env_scrub.py test_agents.py test_runstore_cli.py → 85 passed; test_public_surface test_api_reference test_docs_links → 83 passed; test_repo_hygiene test_changelog_fragments test_runstore_tilde → 51 passed.
  • fail-on-main, live main f5e93335, new test file only → ImportError: cannot import name 'DETACHED_KEEP_VARS' from 'agentdescent.surface_common', 1 error at collection.
  • python -m tools.gen_api_docs --check → docs/api.md is up to date; changelog_fragments check → 40 OK; mkdocs build --strict → exit 0 (16.55 s; social plugin omitted, it fetches card fonts).
  • 9/9 check runs success at the sha (deploy skipped); 0 reviews.

Not run: full pytest -q (the environmental legs: docker, test_era_*, genesis, wheel smoke); a host whose CLAUDE_CONFIG_DIR points at an unauthenticated profile (the resolution path is covered by test_every_host_root_resolves_under_the_scrubbed_environment, not by a live login).

Findings

Blocking: none.

Non-blocking

  1. Wording, not behaviour, at docs/dsh-super-agent.md:110-116 and changelog.d/1082-host-env-scrub.md:2-12: both say the scrub drops "every SESSION_MARKERS variable" and then list XDG_CONFIG_HOME (and, on the page, CLAUDE_CONFIG_DIR) as kept. Two of the eleven are kept on purpose (DETACHED_KEEP_VARS, surface_common.py:255-260), so "every marker except the four config roots" would read without the contradiction a future reader has to resolve from the code.

Risk / merge: T3 by path (agentdescent/runstore.py) and size; the body's tier line and risk:T3 agree. At this sha 0 reviews and no Birfy PASS — the owner's click is the decision.

@Birfy

Birfy commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS

Tip SHA: 85b2974c681daeafca074e01ed86f1a0e9486624 · Risk tier: T3 (path agentdescent/agents.py, agentdescent/runstore.py; risk_gate.py from main f5e93335)

Ran:

  • pytest tests/test_host_env_scrub.py on the tip → 22 passed.
  • python -m tools.changelog_fragments check → 40 fragment(s) OK; python -m tools.gen_api_docs --check → up to date.
  • Required CI (test (3.9)–(3.13), docs build) green on this tip.

Not run: full pytest -q on the box (CI covers the suite); mkdocs build --strict (CI docs / build passed).

This is the tip after CHANGES REQUESTED at 9766bad1. Both asks are in:

  1. The host config dirs a detached run reads survive the scrub. DETACHED_KEEP_VARS is CLAUDE_CONFIG_DIR, CODEX_HOME, DSH_HOME, XDG_CONFIG_HOME — the four names integrations._host_root is called with (the test reads those names out of integrations source). runstore.launch scrubs the merged {**os.environ, PYTHONPATH, **(env or {})}, so a caller env= cannot put MCP_* / DSH_* back, and the launch row keeps the four dirs while dropping CLAUDECODE / DSH_SESSION_ID / MCP_SERVERS.
  2. One denylist: agents.SESSION_MARKERS is surface_common.SESSION_MARKERS. scrub_host_env filters SESSION_MARKERS and then keeps DETACHED_KEEP_VARS. An isolated worker_env still redirects all four into the workspace; isolate=False keeps the user's values.

Minor, not blocking: the scrub_host_env docstring still says "those three" for a four-name keep set.

Partial: #1082 (row 3). Row 2 (per-Ticket budget caps) stays on the issue.

@Birfy
Birfy merged commit 23979b7 into main Nov 15, 2026
9 checks passed
@Birfy
Birfy deleted the agent/1082-surface-write-dirs branch November 15, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-pr Pull request opened by an agent risk:T3 Risk tier T3 (core loop, release, process; needs Danyang's click), set by the controller

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants