Skip to content

Commit 5737598

Browse files
author
IronRod Ops
committed
fix(terminal): keep delegation identity out of shared snapshots
1 parent ce73504 commit 5737598

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Real bash regressions: a shared terminal snapshot is not execution identity."""
2+
import os
3+
import json
4+
import shlex
5+
import sys
6+
import threading
7+
from concurrent.futures import ThreadPoolExecutor
8+
from pathlib import Path
9+
from types import SimpleNamespace
10+
11+
import pytest
12+
13+
from agent.delegation_context import delegated_child_context, is_delegated_child_context
14+
from tools.environments.local import LocalEnvironment
15+
16+
17+
@pytest.mark.parametrize("child_first", [False, True])
18+
def test_parent_identity_survives_child_snapshot(monkeypatch, tmp_path, child_first):
19+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
20+
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fixture_parent")
21+
env = LocalEnvironment(cwd=str(tmp_path), timeout=5)
22+
try:
23+
if not child_first:
24+
env.init_session()
25+
with delegated_child_context():
26+
if child_first:
27+
env.init_session()
28+
child = env.execute('printf "CHILD=%s\\n" "$HERMES_DELEGATED_CHILD_CONTEXT"; export FIXTURE_SHARED=kept', timeout=5)
29+
assert "CHILD=1" in child["output"]
30+
assert not is_delegated_child_context()
31+
assert os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT") is None
32+
parent = env.execute('printf "PARENT=%s SHARED=%s\\n" "${HERMES_DELEGATED_CHILD_CONTEXT:-absent}" "$FIXTURE_SHARED"', timeout=5)
33+
assert "PARENT=absent SHARED=kept" in parent["output"]
34+
finally:
35+
env.cleanup()
36+
37+
38+
@pytest.mark.parametrize("inherited_child", [False, True])
39+
def test_stale_snapshot_cannot_override_spawn_identity(monkeypatch, tmp_path, inherited_child):
40+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
41+
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fixture_parent")
42+
env = LocalEnvironment(cwd=str(tmp_path), timeout=5)
43+
try:
44+
env.init_session()
45+
# Old snapshots exist across code updates. Their identity is not authority.
46+
with Path(env._snapshot_path).open("a") as snapshot:
47+
snapshot.write('declare -x HERMES_DELEGATED_CHILD_CONTEXT="stale"\n')
48+
snapshot.write('declare -x HERMES_KANBAN_TASK="t_stale"\n')
49+
if inherited_child:
50+
monkeypatch.setenv("HERMES_DELEGATED_CHILD_CONTEXT", "1")
51+
result = env.execute('printf "IDENTITY=%s TASK=%s\\n" "${HERMES_DELEGATED_CHILD_CONTEXT:-absent}" "${HERMES_KANBAN_TASK:-absent}"', timeout=5)
52+
expected = "IDENTITY=1 TASK=absent" if inherited_child else "IDENTITY=absent TASK=t_fixture_parent"
53+
assert expected in result["output"]
54+
finally:
55+
env.cleanup()
56+
57+
58+
def test_native_delegation_timeout_parent_cli_and_child_guard(monkeypatch, tmp_path):
59+
"""Real delegate timeout + bash + CLI/DB, with no model or live board."""
60+
from hermes_cli import kanban_db as kb
61+
from tools import delegate_tool
62+
from tests.tools.test_delegate_timeout_cleanup import _SlowUnwindingChild
63+
64+
repo = Path(__file__).resolve().parents[2]
65+
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
66+
db = kb.init_db()
67+
assert db.resolve().is_relative_to(tmp_path.resolve())
68+
with kb.connect() as conn:
69+
tid = kb.create_task(conn, title="fixture", assignee="fixture")
70+
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
71+
env = LocalEnvironment(cwd=str(repo), timeout=10)
72+
73+
def cli(action):
74+
# -c keeps cwd as the import root, avoiding the editable live install.
75+
code = (
76+
"import argparse; from pathlib import Path; "
77+
"from hermes_cli import kanban; "
78+
f"assert Path(kanban.__file__).resolve().is_relative_to(Path({str(repo)!r}).resolve()); "
79+
"p=argparse.ArgumentParser(); s=p.add_subparsers(dest='cmd'); "
80+
"kanban.build_parser(s); "
81+
f"a=p.parse_args(['kanban', {action!r}, '--json']); "
82+
"raise SystemExit(kanban.kanban_command(a))"
83+
)
84+
return env.execute(f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}", timeout=10)
85+
86+
child = _SlowUnwindingChild()
87+
probes = []
88+
parent = SimpleNamespace(session_id="fixture-parent", _current_task_id=None,
89+
_active_children=[child], _active_children_lock=threading.Lock())
90+
monkeypatch.setattr(delegate_tool, "_get_child_timeout", lambda: 5)
91+
monkeypatch.setattr(delegate_tool, "_get_worktree_isolation", lambda: False)
92+
# Hold the fake LLM on an event, but use native timeout/ContextVar machinery.
93+
def child_turn(**kwargs):
94+
probes.append(cli("stats"))
95+
child.started.set()
96+
try:
97+
assert child.allow_finish.wait(30)
98+
return {"final_response": "", "completed": False, "api_calls": 1, "messages": []}
99+
finally:
100+
child.finished.set()
101+
102+
child.run_conversation = child_turn
103+
try:
104+
env.init_session()
105+
with ThreadPoolExecutor(max_workers=1) as pool:
106+
future = pool.submit(delegate_tool._run_single_child, 0, "fixture timeout", child, parent)
107+
assert child.started.wait(15)
108+
result = future.result(timeout=15)
109+
assert result["status"] == "timeout"
110+
assert probes[0]["returncode"] == 1
111+
assert "could not initialize database" in probes[0]["output"]
112+
assert "delegate_task child contexts cannot mutate" in probes[0]["output"]
113+
assert not is_delegated_child_context()
114+
assert os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT") is None
115+
# Still concurrent with the timed-out child, not only after its cleanup.
116+
for action in ("stats", "diagnostics"):
117+
result = cli(action)
118+
assert result["returncode"] == 0, result
119+
json.loads(result["output"])
120+
with kb.connect() as conn:
121+
with delegated_child_context():
122+
with pytest.raises(PermissionError, match="delegate_task child"):
123+
kb.create_task(conn, title="forbidden", assignee="fixture")
124+
task = kb.get_task(conn, tid)
125+
assert task is not None and task.title == "fixture"
126+
assert conn.execute("SELECT count(*) FROM tasks").fetchone()[0] == 1
127+
finally:
128+
child.allow_finish.set()
129+
child.finished.wait(5)
130+
child.closed.wait(5)
131+
env.cleanup()

tools/environments/base.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,8 @@ def _cwd_marker(session_id: str) -> str:
544544
# name/prefix instead of grepping declare lines (see below / issue #71296).
545545
_SNAPSHOT_EXCLUDED_ENV_REGEX = (
546546
"^declare -x (HERMES_SESSION_|HERMES_UI_SESSION_ID|HERMES_CRON_AUTO_DELIVER_|"
547-
"HERMES_CRON_SESSION|HERMES_BROWSER_CONTROL_)"
547+
"HERMES_CRON_SESSION|HERMES_BROWSER_CONTROL_|HERMES_DELEGATED_CHILD_CONTEXT|"
548+
"HERMES_KANBAN_)"
548549
)
549550
_SHELL_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
550551

@@ -589,6 +590,9 @@ def _export_dump_excluding_session_vars(
589590
"{ ( "
590591
"unset ${!HERMES_SESSION_*} ${!HERMES_CRON_AUTO_DELIVER_*} "
591592
"${!HERMES_BROWSER_CONTROL_*} "
593+
# Execution lineage and dispatcher identity are injected per spawn,
594+
# never shell state: a child shares this snapshot with its parent.
595+
"HERMES_DELEGATED_CHILD_CONTEXT ${!HERMES_KANBAN_*} "
592596
# AI_AGENT / HERMES_AGENT are per-command attribution markers
593597
# (re-exported by every _wrap_command with outer-harness-preserving
594598
# ${VAR:-default} semantics). Persisting them into the snapshot
@@ -890,6 +894,15 @@ def _wrap_command(self, command: str, cwd: str) -> str:
890894

891895
parts = []
892896
passthrough_names = self._snapshot_excluded_passthrough_names()
897+
if self.is_local:
898+
from agent.delegation_context import DELEGATED_CHILD_ENV_MARKER, KANBAN_ENV_KEYS
899+
900+
# LocalEnvironment injects the authoritative lineage/worker env on
901+
# every spawn. Even a pre-fix snapshot must not override it (nor
902+
# restore parent worker identity into a scrubbed child process).
903+
passthrough_names = tuple(dict.fromkeys((
904+
*passthrough_names, DELEGATED_CHILD_ENV_MARKER, *KANBAN_ENV_KEYS,
905+
)))
893906

894907
# A shared snapshot may contain the previous profile's value. Save
895908
# the current process environment before sourcing it, then restore the

0 commit comments

Comments
 (0)