Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions agentdescent/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@
import urllib.request
from dataclasses import dataclass, field
from typing import (Any, Callable, Dict, List, Mapping, Optional, Protocol,
Sequence, Tuple, runtime_checkable)
Sequence, runtime_checkable)

from .evolvable import ContractError
from ._subprocess import decode_output
# The host-session denylist is shared with the detached-run scrub in
# ``surface_common``: it used to be two lists, and the shorter one let
# OpenCode/OpenAI-Agents markers through (#1082).
from .surface_common import SESSION_MARKERS, is_session_marker

Completion = Callable[[str], str]

Expand Down Expand Up @@ -270,28 +274,6 @@ def __call__(self, prompt: str) -> str: ...
def in_workspace(self, path: str) -> Completion: ...


#: Environment variables a host agent sets in its own session and its children
#: inherit. A worker that sees them believes it is *inside* that session:
#: Claude Code refuses to start, DSH loads the parent's profile, and a worker
#: whose host runs this package's MCP server would call it recursively.
SESSION_MARKERS: Tuple[str, ...] = (
"CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_CONFIG_DIR", "CODEX_", "DSH_",
"OPENAI_AGENT_", "MCP_",
# OpenCode reads three of these, and redirecting only the directory is not
# isolation: `OPENCODE_CONFIG` (a file) and `OPENCODE_CONFIG_CONTENT`
# (inline JSON) both win over `OPENCODE_CONFIG_DIR` -- measured, a config
# named by `OPENCODE_CONFIG` still supplied its MCP servers with the dir
# pointed at an empty directory. They are dropped by name rather than by an
# "OPENCODE_" prefix on purpose: `OPENCODE_API_KEY` is a provider
# credential, and a worker needs its keys.
"OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT",
# Dropped so the redirect below can take: `setdefault` cannot override a
# value the parent already exported, and this one usually is exported.
# It is broader than the others -- every XDG-respecting tool the worker
# runs sees the workspace copy -- which is the point of an isolated worker.
"XDG_CONFIG_HOME",
)

#: Set for every worker so a tool the worker reaches (this package's own MCP
#: server, when the plugin that hosts it is being evolved) can tell it is inside
#: a run and refuse to start another. See ``agentdescent.mcp``.
Expand Down Expand Up @@ -338,8 +320,7 @@ def worker_env(workspace: Optional[str], extra: Optional[Mapping[str, str]] = No
the user's setup.
"""
env = {k: v for k, v in os.environ.items()
if not (isolate and any(k == m.rstrip("_") or k.startswith(m)
for m in SESSION_MARKERS))}
if not (isolate and is_session_marker(k))}
env[NESTED_MARKER] = "1"
if workspace:
# `cwd=` moves the process, not `$PWD`, and a runtime that resolves its
Expand Down
8 changes: 7 additions & 1 deletion agentdescent/runstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from typing import Any, Dict, Iterator, List, Optional, Sequence

from .evolution import EvolutionResult, RoundInfo
from .surface_common import scrub_host_env

__all__ = [
"STATES",
Expand Down Expand Up @@ -678,7 +679,12 @@ def launch(rd: RunDir, *, budget_usd: Optional[float] = None,
argv += ["--budget", str(budget_usd)]
if usd_per_call is not None:
argv += ["--usd-per-call", str(usd_per_call)]
child_env = {**os.environ, "PYTHONPATH": _child_pythonpath(), **(env or {})}
# Scrub the merged dict, not just the ambient one: a caller-supplied
# ``env`` is spread last and could otherwise put ``MCP_*``/``DSH_*`` back
# into the run after the scrub (#1082). ``PYTHONPATH`` carries no marker
# prefix, so it survives either way.
child_env = scrub_host_env({**os.environ, "PYTHONPATH": _child_pythonpath(),
**(env or {})})
boot = subprocess.run([_python(), "-c", _BOOTSTRAP, rd.log_path, *argv],
capture_output=True, text=True, cwd=rd.path, env=child_env)
if boot.returncode != 0 or not boot.stdout.strip().isdigit():
Expand Down
90 changes: 89 additions & 1 deletion agentdescent/surface_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
import json
import os
import tempfile
from typing import Any, Dict, Iterator, Mapping, Optional, Sequence, Type, TypeVar
from typing import (Any, Dict, Iterator, Mapping, Optional, Sequence, Tuple,
Type, TypeVar)

__all__ = [
"SURFACE_SCHEMA_VERSION",
Expand All @@ -38,6 +39,10 @@
"resolve_ref",
"surface_allow_from_env",
"surface_write_dirs_from_env",
"scrub_host_env",
"SESSION_MARKERS",
"DETACHED_KEEP_VARS",
"is_session_marker",
]

SURFACE_SCHEMA_VERSION = 1
Expand Down Expand Up @@ -200,6 +205,89 @@ def surface_write_dirs_from_env(environ: Optional[Mapping[str, str]] = None
return tuple(part.strip() for part in raw.split(",") if part.strip())


#: Environment variables a host agent sets in its own session and its children
#: inherit. A worker that sees them believes it is *inside* that session: Claude
#: Code refuses to start, DSH loads the parent's profile, and a worker whose
#: host runs this package's MCP server would call it recursively (#1082).
#:
#: One tuple, in this leaf module, because there used to be two lists: the
#: candidate worker (:func:`agentdescent.agents.worker_env`) dropped these
#: eleven names while :func:`scrub_host_env` dropped the four prefixes
#: ``DSH_``/``CLAUDE``/``CODEX``/``MCP_``. Measured on that pair, five of the
#: eleven crossed the scrub -- ``OPENAI_AGENT_*``, ``OPENCODE_CONFIG``,
#: ``OPENCODE_CONFIG_CONTENT``, ``OPENCODE_CONFIG_DIR`` and ``XDG_CONFIG_HOME``
#: -- and the two OpenCode config carriers are the ones measured to hand a
#: nested process the parent's MCP servers. The tuple lives here rather than in
#: ``agents`` because ``agents`` imports ``socket``/``threading``/``urllib``
#: and every surface module imports this one.
SESSION_MARKERS: Tuple[str, ...] = (
"CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_CONFIG_DIR", "CODEX_", "DSH_",
"OPENAI_AGENT_", "MCP_",
# OpenCode reads three of these, and redirecting only the directory is not
# isolation: `OPENCODE_CONFIG` (a file) and `OPENCODE_CONFIG_CONTENT`
# (inline JSON) both win over `OPENCODE_CONFIG_DIR` -- measured, a config
# named by `OPENCODE_CONFIG` still supplied its MCP servers with the dir
# pointed at an empty directory. They are dropped by name rather than by an
# "OPENCODE_" prefix on purpose: `OPENCODE_API_KEY` is a provider
# credential, and a worker needs its keys.
"OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT",
# Dropped so a redirect can take: `setdefault` cannot override a value the
# parent already exported, and this one usually is exported. It is broader
# than the others -- every XDG-respecting tool the child runs sees the
# workspace copy -- which is the point of an isolated worker.
"XDG_CONFIG_HOME",
)


#: The host config/home directories a *detached run* keeps (#1082 review).
#:
#: The candidate worker redirects these into its own workspace
#: (:func:`agentdescent.agents.worker_env`), but the detached run is this
#: package: ``integrations._host_root`` reads all four so a worker spec with
#: ``isolate: false`` -- and every ``agentdescent start`` from a plain terminal
#: -- resolves the user's configured home. Dropping them sent such a run to
#: ``~/.claude`` / ``~/.codex`` / ``~/.config``, which for a custom directory is
#: the wrong, often unauthenticated, login. ``XDG_CONFIG_HOME`` is the one
#: OpenCode actually follows, which is why it is here too. The names are listed
#: here, not imported from ``agents``, because this module stays a leaf; the test
#: ``test_the_keep_set_covers_every_variable_host_root_reads`` reads the names
#: out of ``integrations``' own source, so a fifth host root fails it.
DETACHED_KEEP_VARS: Tuple[str, ...] = (
"CLAUDE_CONFIG_DIR", "CODEX_HOME", "DSH_HOME", "XDG_CONFIG_HOME",
)


def is_session_marker(name: str) -> bool:
"""Whether ``name`` belongs to a host agent's session rather than to the run.

``CLAUDECODE`` is a bare name, not a prefix, so a marker matches when the
variable is the marker without its trailing underscore, or starts with the
marker itself.
"""
upper = name.upper()
return any(upper == marker.rstrip("_") or upper.startswith(marker)
for marker in SESSION_MARKERS)


def scrub_host_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
"""Drop the host session's markers and MCP config from a child environment.

The candidate's gates run under an allowlist (:func:`runners._child_env`),
but a detached run is our own process: it needs the API keys and ``PATH``
that an allowlist would drop. A denylist of the host-session variables keeps
the credentials while the worker's identity -- and any MCP config that would
hand the child a second set of tools -- stays with the host (#1082).

The list is :data:`SESSION_MARKERS`, the same one the candidate worker
drops, so the two cannot drift apart again. The exception is
:data:`DETACHED_KEEP_VARS`: those three name the host's *configuration*, not
its session, and this run resolves a host's real home from them.
"""
env = dict(os.environ if environ is None else environ)
return {name: value for name, value in env.items()
if name.upper() in DETACHED_KEEP_VARS or not is_session_marker(name)}


def _allowed_module(module_name: str, allow: Optional[Sequence[str]]) -> bool:
if allow is None:
return True
Expand Down
12 changes: 12 additions & 0 deletions changelog.d/1082-host-env-scrub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Fixed
A detached run no longer inherits the worker's own session: `runstore.launch`
drops every `SESSION_MARKERS` variable -- `DSH_*`, `CLAUDE*`, `CODEX*`, `MCP_*`,
the OpenCode config carriers and `XDG_CONFIG_HOME` -- before it starts the
grandchild, so the host worker's session identity and MCP server configuration
stay with the host while the credentials and `PATH` the run needs still pass
through (#1082). The list is the one the candidate worker already dropped
(`agentdescent.agents.worker_env`); it used to be a second, shorter copy, and
five of its eleven entries crossed the scrub. The host's config/home
directories (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `DSH_HOME`, `XDG_CONFIG_HOME`)
are kept, because they name configuration rather than session and the run
resolves a host's real home from them (#1082).
11 changes: 11 additions & 0 deletions docs/dsh-super-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ your tree, under the operator's write allowlist).
the Ticket.
- `plan` is the budget line: it estimates cost without executing. Read it before
`start`, and put the number in the Ticket.
- A detached run no longer inherits the worker session: `launch` drops every
host-session marker (`SESSION_MARKERS`: `DSH_*`, `CLAUDE*`, `CODEX*`, `MCP_*`,
the OpenCode config carriers, `XDG_CONFIG_HOME`) before it starts the
grandchild, so the worker's session identity and MCP config stay with the
host while the API keys the run needs are passed through (`#1082`). The list
is the one the candidate worker drops, not a second copy of it. The host's
config/home directories (`DETACHED_KEEP_VARS`: `CLAUDE_CONFIG_DIR`,
`CODEX_HOME`, `DSH_HOME`, `XDG_CONFIG_HOME`) are kept: they name
configuration, not session, and this run resolves a host's real home from
them for `isolate: false` workers and for `agentdescent start` from a
terminal.

## 6. Long runs, Reports and Attention

Expand Down
Loading
Loading