Files
multi-agent-mux/tests/test_issue9_server_env.py
GodopuandClaude Opus 5.5 e8d9151226 test(issue-45): review - the real-server tests run on macOS, and the bootstrap config check follows the bootstrap's python3
claude round 1, B2 and B3 (B1 was the previous commit).

- B2, tests/test_issue45_restore.py: the throwaway root is resolved, because
  on macOS /tmp links to /private/tmp and herdr reports real paths (two B
  tests compared /tmp/... with /private/tmp/...). The server's environment
  is read from /proc on Linux and from `ps -E` elsewhere (macOS has no /proc;
  two A tests failed with FileNotFoundError).
- B3, tests/test_issue9_server_env.py: the bootstrap builds the config with
  the python3 on PATH, which here is 3.9 without tomllib, so the test's
  merged-settings expectation could not hold and its own `import tomllib`
  failed. The test now parses the result with that same python3; when it
  has no tomllib, it expects the key-only config and the bootstrap's
  "no tomllib" warning instead.

macOS, Python 3.9 venv: test_issue45_restore, test_issue9_server_env and
test_issue45_herdr_config 42 passed / 29 skipped (the tomllib-only checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-05 15:59:26 +09:00

351 lines
15 KiB
Python

#!/usr/bin/env python3
"""Issue #9 — herdr server bootstrap must not inherit the caller's agent-runtime
markers, and query-only skills must never bootstrap a server at all.
The bootstrap lives in the `.mam/shim/herdr` wrapper that lib.sh writes, so every
test drives that shim against a fake `herdr` binary placed first on PATH: it logs
each call, answers `session list --json` with "not running", and dumps its own
environment when asked to run as the `server`.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
RECONCILE_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
STATUS_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
FAKE_HERDR = r'''#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
with open(os.environ["FAKE_HERDR_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
if args[-1:] == ["server"]:
with open(os.environ["FAKE_HERDR_ENV_DUMP"], "w") as f:
json.dump(dict(os.environ), f)
sys.exit(0)
if args[-3:] == ["session", "list", "--json"]:
print(json.dumps({"sessions": []}))
sys.exit(0)
print(json.dumps({"result": {"agents": []}}))
'''
# Per-session runtime markers of the calling agent CLI: none of these may reach
# the daemon (and through it, every pane it later spawns).
RUNTIME_MARKERS = {
"CLAUDECODE": "1",
"CLAUDE_CODE_CHILD_SESSION": "1",
"CLAUDE_CODE_SESSION_ID": "orchestrator-uuid",
"CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/cc-socks/1.sock",
"CLAUDE_PID": "12345",
"ANTIGRAVITY_CONVERSATION_ID": "agy-uuid",
"HERMES_SESSION_ID": "hermes-uuid",
"GROK_SESSION_ID": "grok-uuid",
"OPENCODE_SESSION_ID": "opencode-uuid",
}
# Ordinary configuration must keep flowing through unchanged.
KEPT_CONFIG = {
"CLAUDE_CONFIG_DIR": "/keep/claude-config",
"MAM_CUSTOM_SETTING": "kept",
}
# What herdr 0.9 writes to stderr (exit 1) for `agent list` against a stopped
# session server.
REAL_DOWN_ENVELOPE = {
"id": "cli:agent:list",
"error": {
"code": "server_not_running",
"message": "no herdr server is running at /tmp/sessions/down-srv/herdr.sock; "
"run `herdr session attach down-srv` to start or attach it",
},
}
# A successful list in which the down-server code appears only as data: in an
# agent name (the sanitizer allows underscores) and in unrelated metadata.
LISTING_WITH_SUBSTRING = {"result": {"agents": [
{"name": "server_not_running-codex", "agent": "codex"},
{"name": "creator-claude-01", "agent": "claude", "title": "server_not_running"},
]}}
def _install_fake_herdr(sandbox: Path) -> dict:
fakebin = sandbox / "fakebin"
fakebin.mkdir(exist_ok=True)
binary = fakebin / "herdr"
binary.write_text(FAKE_HERDR)
binary.chmod(0o755)
env = dict(os.environ)
env.update({
"PATH": f"{fakebin}:{env['PATH']}",
"FAKE_HERDR_LOG": str(sandbox / "fake_herdr_calls.log"),
"FAKE_HERDR_ENV_DUMP": str(sandbox / "server_env.json"),
"AGENT_PYTHON_BIN": sys.executable,
"DELEGATE_JOB_PYTHON": sys.executable,
})
return env
def _fake_agent_list(sandbox: Path, body: str) -> None:
"""Replace the fake's fallback answer (what `agent list` gets) with `body`."""
fake = sandbox / "fakebin" / "herdr"
fake.write_text(FAKE_HERDR.replace('print(json.dumps({"result": {"agents": []}}))', body))
def _calls(env: dict) -> list:
log = Path(env["FAKE_HERDR_LOG"])
if not log.exists():
return []
return [json.loads(line) for line in log.read_text().splitlines() if line.strip()]
def _run_through_shim(sandbox: Path, env: dict, session: str) -> subprocess.CompletedProcess:
"""`herdr agent list` through lib.sh's shim, scoped to an isolated session
whose server the fake reports as not running."""
lib = sandbox / ".agents" / "skills" / "lib.sh"
env = dict(env, HERDR_SESSION_NAME=session)
return subprocess.run(
["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr agent list'],
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60,
)
def _shim_ls(sandbox: Path, env: dict, server: str) -> subprocess.CompletedProcess:
"""The exact `ls` query reconcile.sh sends for each registered server."""
lib = sandbox / ".agents" / "skills" / "lib.sh"
return subprocess.run(
["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L {server} ls -F "#{{session_name}}|#{{session_created}}"'],
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60,
)
def test_bootstrap_strips_agent_runtime_markers(mam_sandbox):
env = _install_fake_herdr(mam_sandbox)
env.update(RUNTIME_MARKERS)
env.update(KEPT_CONFIG)
res = _run_through_shim(mam_sandbox, env, "issue9-probe")
assert res.returncode == 0, res.stderr
server_calls = [c for c in _calls(env) if c[-1:] == ["server"]]
assert server_calls == [["--session", "issue9-probe", "server"]], _calls(env)
dump = Path(env["FAKE_HERDR_ENV_DUMP"])
assert dump.exists(), "server was bootstrapped but never received an environment"
server_env = json.loads(dump.read_text())
leaked = sorted(k for k in RUNTIME_MARKERS if k in server_env)
assert not leaked, f"runtime markers leaked into the herdr server: {leaked}"
for key, value in KEPT_CONFIG.items():
assert server_env.get(key) == value, f"{key} must be inherited unchanged"
# MAM's own configuration is configuration, not a marker.
assert server_env.get("CLAUDE_PROJECT_DIR") == os.environ["CLAUDE_PROJECT_DIR"]
for key in ("HOME", "PATH", "WORKSPACE_ROOT"):
assert key in server_env
def test_bootstrap_hands_the_server_a_no_restore_config(mam_sandbox):
"""Issue #45: the server starts with HERDR_CONFIG_PATH = the user's herdr
config plus [session] resume_agents_on_restore = false (that herdr obeys it
is test_issue45_restore.py, on a real server)."""
env = _install_fake_herdr(mam_sandbox)
user_cfg = mam_sandbox / ".config" / "herdr" / "config.toml"
user_cfg.parent.mkdir(parents=True)
user_cfg.write_text("[ui]\nconfirm_close = false\n")
res = _run_through_shim(mam_sandbox, env, "issue45-probe")
assert res.returncode == 0, res.stderr
assert "restore-on-restart stays enabled" not in res.stderr, res.stderr
server_env = json.loads(Path(env["FAKE_HERDR_ENV_DUMP"]).read_text())
assert server_env.get("HERDR_CONFIG_PATH") == str(user_cfg.parent / "mam-server-config.toml")
assert "MAM_SERVER_CFG" not in server_env
# The bootstrap builds the file with the python3 on PATH; parse it with that
# same python3. Without tomllib there (Python < 3.11, macOS's 3.9) the file is
# the key-only config and the bootstrap warns that the other settings are gone.
out = Path(server_env["HERDR_CONFIG_PATH"])
parse = subprocess.run(["python3", "-c", "import json, sys, tomllib; "
"print(json.dumps(tomllib.loads(open(sys.argv[1]).read())))", str(out)],
capture_output=True, text=True, env=env, timeout=30)
if parse.returncode == 0:
assert json.loads(parse.stdout) == {"ui": {"confirm_close": False},
"session": {"resume_agents_on_restore": False}}
else:
assert "No module named 'tomllib'" in parse.stderr, parse.stderr
text = out.read_text()
assert "resume_agents_on_restore = false" in text and "confirm_close" not in text, text
assert "no tomllib" in res.stderr, res.stderr
def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox):
env = _install_fake_herdr(mam_sandbox)
env["MAM_NO_AUTOSTART"] = "1"
res = _run_through_shim(mam_sandbox, env, "issue9-quiet")
assert res.returncode == 0, res.stderr
calls = _calls(env)
assert not any(c[-1:] == ["server"] for c in calls), f"server must not be started: {calls}"
assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists()
# The command itself still goes to the named session, whose server answers.
assert ["--session", "issue9-quiet", "agent", "list"] in calls, calls
def test_status_query_never_starts_a_server(mam_sandbox):
"""status.sh sweeps every herdr_session named in the YAML; a stopped
isolated server must be reported dead, not bootstrapped to answer."""
env = _install_fake_herdr(mam_sandbox)
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n"
" - name: ghost-creator-claude\n"
" status: stopped\n"
" herdr_session: ghost-srv\n"
" pane:\n"
" cwd: /tmp/nonexistent-workspace\n"
)
status_sh = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(status_sh), "--json"], capture_output=True, text=True,
cwd=str(mam_sandbox), env=env, timeout=120)
assert res.returncode == 0, res.stderr
calls = _calls(env)
assert any("--session" in c and "ghost-srv" in c for c in calls), \
f"the ghost-srv session was never queried: {calls}"
assert not any(c[-1:] == ["server"] for c in calls), f"status.sh started a server: {calls}"
assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists()
def test_reconcile_and_status_opt_out_of_autostart_statically():
for script in (RECONCILE_SH, STATUS_SH):
assert "export MAM_NO_AUTOSTART=1" in script.read_text(), \
f"{script.name} must opt out of the shim's server bootstrap"
def _fail_with(text: str, stream: str) -> str:
"""Fake `agent list` body: write `text` to `stream` and exit 1."""
return f'sys.{stream}.write({json.dumps(text)} + "\\n"); sys.exit(1)'
# reconcile.sh reads a nonzero `ls` whose stderr contains any of these as a
# confirmed-EMPTY server (`is_empty`); anything else as unknown.
RECONCILE_EMPTY_PHRASES = ("no server running", "no sessions", "failed to connect")
# An unrelated structured failure whose message happens to mention the code.
TRANSPORT_ERROR = json.dumps({"error": {"code": "transport_error",
"message": "socket reset while probing server_not_running"}})
@pytest.mark.parametrize("envelope,stream", [
({"error": {"code": "server_not_running"}}, "stderr"),
(REAL_DOWN_ENVELOPE, "stderr"),
({"error": {"code": "server_not_running"}}, "stdout"),
], ids=["minimal", "herdr-0.9", "minimal-on-stdout"])
def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope, stream):
"""reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says
'no server running'; anything else flips herdr_confirmed=False for every
server. The shim must therefore translate server_not_running explicitly."""
env = _install_fake_herdr(mam_sandbox)
env["MAM_NO_AUTOSTART"] = "1"
_fake_agent_list(mam_sandbox, _fail_with(json.dumps(envelope), stream))
res = _shim_ls(mam_sandbox, env, "down-srv")
assert res.returncode != 0
assert "no server running" in res.stderr, res.stderr
assert res.stdout.strip() == ""
def test_ls_lists_agents_whose_names_contain_server_not_running(mam_sandbox):
"""A successful list is never a down server, whatever text it carries.
Matching the bare substring let one agent named server_not_running-codex
hide every agent its server returned."""
env = _install_fake_herdr(mam_sandbox)
env["MAM_NO_AUTOSTART"] = "1"
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
res = _shim_ls(mam_sandbox, env, "live-srv")
assert res.returncode == 0, res.stderr
assert "no server running" not in res.stderr, res.stderr
names = [line.split("|", 1)[0] for line in res.stdout.splitlines() if line.strip()]
assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout
@pytest.mark.parametrize("text,stream", [
(TRANSPORT_ERROR, "stderr"),
(TRANSPORT_ERROR, "stdout"),
("warning: server_not_running cache entry is stale", "stderr"),
("", "stderr"),
], ids=["other-code", "other-code-on-stdout", "plain-text", "empty"])
def test_ls_leaves_other_failures_unknown(mam_sandbox, text, stream):
"""Only the structured code means "down". Any other failure must stay
unknown - nonzero exit, no empty-server phrase - wherever its error
document lands. On stdout it used to reach the list parser, which read a
JSON error without result.agents as a successful EMPTY list (rc 0)."""
env = _install_fake_herdr(mam_sandbox)
env["MAM_NO_AUTOSTART"] = "1"
_fake_agent_list(mam_sandbox, _fail_with(text, stream))
res = _shim_ls(mam_sandbox, env, "flaky-srv")
assert res.returncode != 0, f"a failed list must not exit 0: {res.stdout!r} {res.stderr!r}"
for phrase in RECONCILE_EMPTY_PHRASES:
assert phrase not in res.stderr.lower(), res.stderr
assert res.stdout.strip() == ""
def _write_running_rows(sandbox: Path, names, server: str = "live-srv") -> None:
rows = "".join(
f" - name: {name}\n"
" status: running\n"
f" herdr_session: {server}\n"
" pane:\n"
" cwd: /tmp/nonexistent-workspace\n"
for name in names
)
(sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows)
def _reconcile_dry_run(sandbox: Path, env: dict) -> dict:
reconcile = sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"],
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox):
"""End to end through reconcile.sh: the server answers a successful list in
which the down-server code appears as data. Both registered rows must stay
alive with herdr_confirmed intact, so drift A cannot mark them terminated."""
env = _install_fake_herdr(mam_sandbox)
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
_write_running_rows(mam_sandbox, ("server_not_running-codex", "creator-claude-01"))
report = _reconcile_dry_run(mam_sandbox, env)
assert report["herdr_confirmed"] is True, report
alive = set(report["herdr_sessions_alive"])
assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
assert not drift_a, drift_a
def test_reconcile_treats_other_list_failures_as_unknown(mam_sandbox):
"""End to end through reconcile.sh: a server whose `agent list` fails with
an unrelated structured error on stdout must drop herdr_confirmed, so
drift A never marks its live row terminated."""
env = _install_fake_herdr(mam_sandbox)
_fake_agent_list(mam_sandbox, _fail_with(TRANSPORT_ERROR, "stdout"))
_write_running_rows(mam_sandbox, ("creator-claude-01",))
report = _reconcile_dry_run(mam_sandbox, env)
assert report["herdr_confirmed"] is False, report
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
assert not drift_a, drift_a
terminated = [a for a in report.get("actions", []) if str(a).startswith("terminated")]
assert not terminated, terminated