From 525df2bb585c24f436b17eebfa1c1ce4db928d63 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 13 Oct 2026 09:15:22 +0900 Subject: [PATCH] fix(tests): tests no longer reach a real herdr server - a *-shim ancestor hid the mock, the sandbox kept the pane's socket A suite run from an agent pane with --basetemp .../pytest-pr56-shim left seven workspaces on that pane's real server (multi-agent-mux): - the real-herdr resolvers (lib.sh _is_shim_path and the generated shim) skipped every PATH directory with "-shim" anywhere in its path, so the basetemp hid the mock's bin/ and the real herdr ran; only a directory that is itself *-shim is a wrapper now; - mam_sandbox kept HERDR_SOCKET_PATH (and the pane/tab ids), which the real herdr follows over the sandbox HOME; it drops them now. tests/test_herdr_real_server_leak.py: L1 (ancestor *-shim, both resolvers) and L2 (pane routing dropped) fail before the fix; L1b keeps a *-shim wrapper directory excluded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib.sh | 12 +++- tests/conftest.py | 6 ++ tests/test_herdr_real_server_leak.py | 87 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/test_herdr_real_server_leak.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index d9927d9..31f14cb 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -112,9 +112,15 @@ _canonical_file() { _is_shim_path() { case "/$1/" in - *"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*) + *"/.mam/shim/"*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*) return 0 ;; esac + # A wrapper directory named *-shim, the directory itself only: an ancestor + # such as a pytest basetemp "pytest-x-shim" above a mock's bin/ made the + # resolver skip the mock and run the real herdr. + case "${1%/}" in + *-shim) return 0 ;; + esac return 1 } @@ -127,7 +133,7 @@ _resolve_real_herdr_path() { [ -x "$dir/herdr" ] || continue cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr" [ -n "$cand" ] || cand="$dir/herdr" - _is_shim_path "$cand" && continue + _is_shim_path "$(dirname "$cand")" && continue real_path="$dir/herdr" break done @@ -167,7 +173,7 @@ _resolve_real_herdr() { local dir save_ifs="$IFS" real_path="" IFS=: for dir in $PATH; do - if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then + if [[ "$dir" != *".mam/shim"* ]] && [[ "${dir%/}" != *-shim ]] && [ -x "$dir/herdr" ]; then real_path="$dir/herdr" break fi diff --git a/tests/conftest.py b/tests/conftest.py index dcae90c..037c5bf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -290,6 +290,12 @@ def mam_sandbox(tmp_path, monkeypatch, request): monkeypatch.delenv("HERDR_SESSION_NAME", raising=False) monkeypatch.delenv("HERDR_SERVER_NAME", raising=False) monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False) + # A herdr pane exports its server's socket (and pane/tab ids). The real + # herdr follows HERDR_SOCKET_PATH over the sandbox HOME, so a call that + # missed the mock reached the caller's real server and opened panes there. + monkeypatch.delenv("HERDR_SOCKET_PATH", raising=False) + monkeypatch.delenv("HERDR_PANE_ID", raising=False) + monkeypatch.delenv("HERDR_TAB_ID", raising=False) # Issue #45: the shim bootstrap writes a herdr server config next to the # user's; with these unset that is $HOME/.config/herdr, inside the sandbox. # Every pane of a MAM server inherits HERDR_CONFIG_PATH. diff --git a/tests/test_herdr_real_server_leak.py b/tests/test_herdr_real_server_leak.py new file mode 100644 index 0000000..ecf71bb --- /dev/null +++ b/tests/test_herdr_real_server_leak.py @@ -0,0 +1,87 @@ +"""Tests must not reach a real herdr server (found 2026-10-12). + +A suite run from an agent pane with --basetemp .../pytest-pr56-shim left seven +workspaces on that pane's real server. Two causes, one test each: +- L1: the real-herdr resolvers skipped every PATH directory with "-shim" + anywhere in its path, so the basetemp hid the mock's bin/ and the real + herdr ran; +- L2: the sandbox kept the pane's HERDR_SOCKET_PATH, which the real herdr + follows over the sandbox HOME. +""" +import os +import subprocess + +import pytest + +DECOY = "#!/bin/sh\necho \"$@\" >> \"$DECOY_LOG\"\necho '{}'\n" + + +def test_l1_ancestor_named_shim_does_not_hide_the_mock(mam_sandbox, mock_herdr): + tmp = mam_sandbox + # The mock's bin/ under an ancestor named *-shim, as under that basetemp. + mock_bin = tmp / "pytest-x-shim" / "case0" / "bin" + mock_bin.mkdir(parents=True) + (mock_bin / "herdr").symlink_to(tmp / "bin" / "herdr") + # A decoy next in PATH stands in for the real herdr: with the old match it + # is what ran. The real herdr is never reached either way. + decoy_bin = tmp / "decoy" / "bin" + decoy_bin.mkdir(parents=True) + (decoy_bin / "herdr").write_text(DECOY) + (decoy_bin / "herdr").chmod(0o755) + decoy_log = tmp / "decoy.log" + env = dict(os.environ, PATH=f"{mock_bin}:{decoy_bin}:{os.environ['PATH']}", + DECOY_LOG=str(decoy_log)) + lib = tmp / ".agents" / "skills" / "lib.sh" + script = f""" + source "{lib}" + _init_herdr_isolation + _resolve_real_herdr_path + "$WORKSPACE_ROOT/.mam/shim/herdr" workspace list >/dev/null + """ + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp), env=env) + assert res.returncode == 0, res.stderr + # lib.sh's resolver picks the mock's directory, not the decoy. + assert res.stdout.strip().splitlines()[0] == f"{mock_bin}/herdr", res.stdout + # The generated shim ran the mock too. + assert not decoy_log.exists(), decoy_log.read_text() + + +def test_l1_a_directory_named_shim_is_still_skipped(mam_sandbox, mock_herdr): + """A wrapper directory that is itself *-shim stays excluded.""" + tmp = mam_sandbox + wrapper = tmp / "herdr-shim" + wrapper.mkdir() + (wrapper / "herdr").write_text(DECOY) + (wrapper / "herdr").chmod(0o755) + decoy_log = tmp / "decoy.log" + env = dict(os.environ, PATH=f"{wrapper}:{os.environ['PATH']}", DECOY_LOG=str(decoy_log)) + lib = tmp / ".agents" / "skills" / "lib.sh" + script = f""" + source "{lib}" + _init_herdr_isolation + _resolve_real_herdr_path + "$WORKSPACE_ROOT/.mam/shim/herdr" workspace list >/dev/null + """ + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + cwd=str(tmp), env=env) + assert res.returncode == 0, res.stderr + assert res.stdout.strip().splitlines()[0] == f"{tmp}/bin/herdr", res.stdout + assert not decoy_log.exists(), decoy_log.read_text() + + +PANE_VARS = {"HERDR_SOCKET_PATH": "/nonexistent/sessions/real/herdr.sock", + "HERDR_PANE_ID": "w9:p1", "HERDR_TAB_ID": "w9:t1", + "HERDR_WORKSPACE_ID": "w9", "HERDR_SESSION_NAME": "real"} + + +@pytest.fixture +def _agent_pane_env(monkeypatch): + """What an agent pane on a named server carries; set before mam_sandbox.""" + for k, v in PANE_VARS.items(): + monkeypatch.setenv(k, v) + + +def test_l2_sandbox_drops_the_pane_routing(_agent_pane_env, mam_sandbox): + left = sorted(k for k in PANE_VARS if k in os.environ) + assert left == [], left