Add human-readable --format table, one-page brief, claim archive via docs apply CAS, and four MCP readonly tools. Includes P1 console design doc and engineering-tool test evidence.
1490 lines
71 KiB
Python
1490 lines
71 KiB
Python
#!/usr/bin/env python3
|
||
"""Black-box regressions for the local PI subagent executor (no paid model calls)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import fcntl
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import pathlib
|
||
import selectors
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||
SOURCE_AGENTCTL = ROOT / "scripts" / "agentctl.py"
|
||
SOURCE_W4 = ROOT / "scripts" / "agentctl_w4.py"
|
||
SOURCE_EXEC = ROOT / "scripts" / "pi_subagents.py"
|
||
SOURCE_MCP = ROOT / "scripts" / "mcp" / "pi-subagents-mcp.mjs"
|
||
SOURCE_EXT = ROOT / "scripts" / "pi-subagent-readonly.ts"
|
||
POLICY_SOURCE = ROOT / "configs" / "agent-governance.json"
|
||
MODEL_CONFIG_SOURCE = ROOT / ".doc" / "ops" / "2026-09-24-subagent-model-config.md"
|
||
|
||
KNOWN_ROLES = (
|
||
"research", "planning", "requirements_review", "plan_review", "code_review",
|
||
"diagnosis", "test_run", "acceptance_check", "stage_notes", "report", "auxiliary",
|
||
)
|
||
KNOWN_ALIASES = ("qwen_max", "qwen_flash", "glm_full", "glm_flash", "minimax_aux")
|
||
|
||
|
||
def sha256_text(text: str) -> str:
|
||
return hashlib.sha256(text.encode()).hexdigest()
|
||
|
||
|
||
FAKE_PI = r'''#!/usr/bin/env python3
|
||
import json, os, sys, time, pathlib
|
||
argv_log = os.environ.get("FAKE_PI_ARGV_LOG")
|
||
if argv_log:
|
||
pathlib.Path(argv_log).write_text(json.dumps(sys.argv[1:]), encoding="utf-8")
|
||
snap_probe = os.environ.get("FAKE_PI_SNAP_PROBE")
|
||
if snap_probe:
|
||
cwd = pathlib.Path(os.getcwd()).resolve()
|
||
snap_env = os.environ.get("PI_SUBAGENT_SNAPSHOT_ROOT") or str(cwd)
|
||
snap_root = pathlib.Path(snap_env).resolve()
|
||
inventory = []
|
||
for root, dirs, files in os.walk(snap_root):
|
||
dirs[:] = sorted(d for d in dirs if d != ".pi-subagent-snapshot")
|
||
rel = pathlib.Path(root).relative_to(snap_root)
|
||
for name in sorted(files):
|
||
if name == ".pi-subagent-snapshot":
|
||
continue
|
||
path = (rel / name).as_posix() if str(rel) != "." else name
|
||
inventory.append(path)
|
||
ext_arg = None
|
||
args = sys.argv[1:]
|
||
for i, a in enumerate(args):
|
||
if a in ("-e", "--extension") and i + 1 < len(args):
|
||
ext_arg = args[i + 1]
|
||
break
|
||
ext_exists = bool(ext_arg and pathlib.Path(ext_arg).is_file())
|
||
inside = False
|
||
if ext_arg:
|
||
try:
|
||
pathlib.Path(ext_arg).resolve().relative_to(snap_root)
|
||
inside = True
|
||
except ValueError:
|
||
inside = False
|
||
pathlib.Path(snap_probe).write_text(json.dumps({
|
||
"cwd": str(cwd),
|
||
"snapshot_root": str(snap_root),
|
||
"inventory": inventory,
|
||
"extension": ext_arg,
|
||
"extension_exists": ext_exists,
|
||
"extension_inside_snapshot": inside,
|
||
}), encoding="utf-8")
|
||
mode = os.environ.get("FAKE_PI_MODE", "ok")
|
||
provider = os.environ.get("FAKE_PI_PROVIDER", "qwen-token-plan-cn")
|
||
model = os.environ.get("FAKE_PI_MODEL", "qwen3.8-flash")
|
||
text = os.environ.get("FAKE_PI_TEXT", "review complete: looks fine")
|
||
sleep_s = float(os.environ.get("FAKE_PI_SLEEP", "0"))
|
||
if sleep_s:
|
||
time.sleep(sleep_s)
|
||
if mode == "hang":
|
||
time.sleep(3600)
|
||
if mode == "crash":
|
||
sys.exit(42)
|
||
events = [{"type": "session", "version": 3, "id": "fake", "cwd": os.getcwd()}]
|
||
if mode != "no_agent_start":
|
||
events.append({"type": "agent_start"})
|
||
events.append({"type": "turn_start"})
|
||
msg = {
|
||
"role": "assistant",
|
||
"content": [{"type": "text", "text": text}],
|
||
"stopReason": (
|
||
"error" if mode == "stop_error"
|
||
else ("aborted" if mode == "aborted"
|
||
else ("toolUse" if mode == "tool_use_stop" else "stop"))
|
||
),
|
||
"provider": provider,
|
||
"model": model,
|
||
}
|
||
if mode == "empty_text":
|
||
msg["content"] = [{"type": "text", "text": ""}]
|
||
if mode == "thinking_only":
|
||
msg["content"] = [{"type": "thinking", "thinking": "secret thoughts"}]
|
||
if mode != "no_message_end":
|
||
events.append({"type": "message_end", "message": msg})
|
||
events.append({"type": "turn_end", "message": msg, "toolResults": []})
|
||
if mode not in ("no_agent_end", "crash_mid"):
|
||
will_retry = mode == "will_retry"
|
||
events.append({"type": "agent_end", "messages": [msg], "willRetry": will_retry})
|
||
if mode not in ("no_settled",) and not will_retry:
|
||
events.append({"type": "agent_settled"})
|
||
if mode == "activity_after_settled":
|
||
events.append({"type": "agent_start"})
|
||
for ev in events:
|
||
print(json.dumps(ev), flush=True)
|
||
if mode == "nonzero":
|
||
sys.exit(7)
|
||
sys.exit(0)
|
||
'''
|
||
|
||
|
||
BASE_CONFIG = MODEL_CONFIG_SOURCE.read_text(encoding="utf-8") if MODEL_CONFIG_SOURCE.exists() else ""
|
||
|
||
|
||
class PiSubagentsTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.assertTrue(SOURCE_AGENTCTL.exists(), "agentctl.py must exist")
|
||
self.temp = tempfile.TemporaryDirectory(prefix="pi-subagent-test-")
|
||
self.addCleanup(self.temp.cleanup)
|
||
self.root = pathlib.Path(self.temp.name) / "repo"
|
||
self.root.mkdir()
|
||
self.git("init", "-b", "dev")
|
||
self.git("config", "user.name", "PI Subagent Test")
|
||
self.git("config", "user.email", "pi-subagent-test@example.invalid")
|
||
(self.root / "scripts" / "mcp").mkdir(parents=True)
|
||
(self.root / ".cursor").mkdir(parents=True)
|
||
shutil.copy2(SOURCE_AGENTCTL, self.root / "scripts" / "agentctl.py")
|
||
if SOURCE_W4.exists():
|
||
shutil.copy2(SOURCE_W4, self.root / "scripts" / "agentctl_w4.py")
|
||
catalog_src = ROOT / "scripts" / "agentctl_catalog.py"
|
||
if catalog_src.exists():
|
||
shutil.copy2(catalog_src, self.root / "scripts" / "agentctl_catalog.py")
|
||
vis_src = ROOT / "scripts" / "agentctl_visibility.py"
|
||
if vis_src.exists():
|
||
shutil.copy2(vis_src, self.root / "scripts" / "agentctl_visibility.py")
|
||
if SOURCE_EXEC.exists():
|
||
shutil.copy2(SOURCE_EXEC, self.root / "scripts" / "pi_subagents.py")
|
||
if SOURCE_MCP.exists():
|
||
shutil.copy2(SOURCE_MCP, self.root / "scripts" / "mcp" / "pi-subagents-mcp.mjs")
|
||
if SOURCE_EXT.exists():
|
||
shutil.copy2(SOURCE_EXT, self.root / "scripts" / "pi-subagent-readonly.ts")
|
||
dest = self.root / "configs"
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(POLICY_SOURCE, dest / "agent-governance.json")
|
||
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
|
||
for directory in ("src", ".doc/ops", ".doc/status", ".doc/test-reports/assets"):
|
||
(self.root / directory).mkdir(parents=True, exist_ok=True)
|
||
(self.root / "src" / "sample.py").write_text("print('hello')\n", encoding="utf-8")
|
||
(self.root / ".doc" / "ops" / "CURRENT.md").write_text(
|
||
"# Coordination\n\n| Task | Owner | Branch | State |\n| --- | --- | --- | --- |\n"
|
||
"| alpha | Alice | feature/alpha | active |\n",
|
||
encoding="utf-8")
|
||
(self.root / ".doc" / "status" / "FEATURE-MATRIX.md").write_text(
|
||
"# Features\n\n| record_id | 模块 | 状态 |\n| --- | --- | --- |\n| core | core | planned |\n",
|
||
encoding="utf-8")
|
||
self.config_path = self.root / ".doc" / "ops" / "2026-09-24-subagent-model-config.md"
|
||
self.config_path.write_text(BASE_CONFIG or self._minimal_config(), encoding="utf-8")
|
||
self.fake_pi = pathlib.Path(self.temp.name) / "fake-pi"
|
||
self.fake_pi.write_text(FAKE_PI, encoding="utf-8")
|
||
self.fake_pi.chmod(0o755)
|
||
self.argv_log = pathlib.Path(self.temp.name) / "pi-argv.json"
|
||
self.git("add", ".")
|
||
self.git("commit", "-m", "fixture")
|
||
self.source_sha = self.git("rev-parse", "HEAD")
|
||
|
||
def _minimal_config(self):
|
||
return """# Subagent 模型分配配置
|
||
|
||
config_revision:`subagent-models-2026-09-24-r1`
|
||
|
||
## 2. 允许的模型池
|
||
|
||
| alias | provider | model(精确 ID) | 使用限制 |
|
||
|---|---|---|---|
|
||
| qwen_max | qwen-token-plan-cn | qwen3.8-max | ok |
|
||
| qwen_flash | qwen-token-plan-cn | qwen3.8-flash | ok |
|
||
| glm_full | zai-coding-cn | glm-5.3 | ok |
|
||
| glm_flash | zai-coding-cn | glm-5.3-flash | ok |
|
||
| minimax_aux | minimax-cn | MiniMax-M3 | aux only |
|
||
|
||
## 3. 角色路由表
|
||
|
||
| role_id | 工作 | enabled | 默认模型 | 升级角色 | 执行边界 |
|
||
|---|---|---|---|---|---|
|
||
| research | r | true | qwen_flash | planning | ro |
|
||
| planning | p | true | qwen_max | none | ro |
|
||
| requirements_review | rr | true | glm_full | none | ro |
|
||
| plan_review | pr | true | glm_full | none | ro |
|
||
| code_review | cr | true | glm_full | none | ro |
|
||
| diagnosis | d | true | glm_full | none | ro |
|
||
| test_run | t | true | glm_flash | diagnosis | test |
|
||
| acceptance_check | a | true | glm_flash | diagnosis | check |
|
||
| stage_notes | s | true | qwen_flash | report | notes |
|
||
| report | rp | true | qwen_max | none | report |
|
||
| auxiliary | aux | false | minimax_aux | none | aux |
|
||
|
||
## 4. 执行与升级参数
|
||
|
||
| 参数 | 默认值 | 语义 |
|
||
|---|---|---|
|
||
| max_parallel_pi_tasks | 2 | limit |
|
||
| implementation_executor | cursor_auto | cursor only |
|
||
| unavailable_action | pause_role | pause |
|
||
| automatic_fallback | none | no fallback |
|
||
| reload_boundary | next_dispatch | next |
|
||
| recursive_delegation | false | no recurse |
|
||
"""
|
||
|
||
def git(self, *args, cwd=None):
|
||
return subprocess.run(
|
||
["git", *args], cwd=cwd or self.root, text=True, capture_output=True, check=True
|
||
).stdout.strip()
|
||
|
||
def env(self, extra=None):
|
||
base = {
|
||
**os.environ,
|
||
"PI_SUBAGENT_PI_BIN": str(self.fake_pi),
|
||
"FAKE_PI_ARGV_LOG": str(self.argv_log),
|
||
"FAKE_PI_MODE": "ok",
|
||
"FAKE_PI_PROVIDER": "qwen-token-plan-cn",
|
||
"FAKE_PI_MODEL": "qwen3.8-flash",
|
||
"PYTHONDONTWRITEBYTECODE": "1",
|
||
}
|
||
# Drop inherited auth/runtime pointers; tests inject only what they need.
|
||
for key in list(base):
|
||
if key.startswith("AGENTCTL_") or key.startswith("PI_CODING_") or key == "XDG_RUNTIME_DIR":
|
||
base.pop(key, None)
|
||
if extra:
|
||
base.update(extra)
|
||
for key in list(base):
|
||
if key.startswith("AGENTCTL_"):
|
||
base.pop(key, None)
|
||
return base
|
||
|
||
def cli(self, *args, cwd=None, check=True, env=None, stdin=None):
|
||
script = self.root / "scripts" / "pi_subagents.py"
|
||
result = subprocess.run(
|
||
[sys.executable, "-B", str(script), *args],
|
||
cwd=cwd or self.root,
|
||
text=True,
|
||
capture_output=True,
|
||
env=env or self.env(),
|
||
input=stdin,
|
||
timeout=60,
|
||
)
|
||
if check:
|
||
self.assertEqual(result.returncode, 0, result.stdout + "\n" + result.stderr)
|
||
return result
|
||
|
||
def agentctl(self, *args, cwd=None, check=True, env=None):
|
||
result = subprocess.run(
|
||
[sys.executable, "-B", str(self.root / "scripts" / "agentctl.py"), *args],
|
||
cwd=cwd or self.root,
|
||
text=True,
|
||
capture_output=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
if check:
|
||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||
return result
|
||
|
||
def initialized_task(self, task="alpha", scopes=None):
|
||
scopes = scopes or (
|
||
"src",
|
||
".doc",
|
||
".doc/test-reports/assets/pi-subagents",
|
||
)
|
||
if not (self.root / ".locks").exists():
|
||
self.agentctl("init")
|
||
path = self.root / ".worktrees" / task
|
||
self.git("worktree", "add", "-b", "feature/" + task, str(path), "dev")
|
||
args = ["task", "register", "--id", task, "--owner", "Test Agent"]
|
||
for scope in scopes:
|
||
args.extend(("--scope", scope))
|
||
self.agentctl(*args, cwd=path)
|
||
return path
|
||
|
||
def last_json(self, text):
|
||
lines = [ln for ln in text.splitlines() if ln.strip()]
|
||
self.assertTrue(lines, text)
|
||
return json.loads(lines[-1])
|
||
|
||
def result_json(self, result):
|
||
"""Prefer executor JSON (code/run_status) over agentctl status lines on stderr."""
|
||
candidates = []
|
||
for blob in (result.stdout, result.stderr):
|
||
for ln in (blob or "").splitlines():
|
||
ln = ln.strip()
|
||
if not ln:
|
||
continue
|
||
try:
|
||
obj = json.loads(ln)
|
||
except Exception:
|
||
continue
|
||
if isinstance(obj, dict):
|
||
candidates.append(obj)
|
||
for obj in reversed(candidates):
|
||
if "code" in obj or "run_status" in obj or "roles" in obj or "models" in obj:
|
||
return obj
|
||
self.assertTrue(candidates, result.stdout + "\n" + result.stderr)
|
||
return candidates[-1]
|
||
|
||
def report_dir(self, task, request_id, worktree=None):
|
||
base = worktree or self.root
|
||
return base / ".doc" / "test-reports" / "assets" / "pi-subagents" / task / request_id
|
||
|
||
def base_request(self, **overrides):
|
||
req = {
|
||
"task_id": "alpha",
|
||
"role_id": "research",
|
||
"request_id": "req-001",
|
||
"goal": "Locate sample.py and summarize",
|
||
"authorization_ref": "user:test-auth",
|
||
"source_sha": self.source_sha,
|
||
"input_files": ["src/sample.py"],
|
||
"timeout_seconds": 30,
|
||
}
|
||
req.update(overrides)
|
||
return req
|
||
|
||
def run_under_lease(self, task_path, request, resources=None, env_extra=None, check=True):
|
||
resources = resources or [
|
||
f"path:.doc/test-reports/assets/pi-subagents/{request['task_id']}/{request['request_id']}"
|
||
]
|
||
req_path = pathlib.Path(self.temp.name) / f"{request['request_id']}.json"
|
||
req_path.write_text(json.dumps(request), encoding="utf-8")
|
||
env = self.env(env_extra)
|
||
wt_config = task_path / ".doc/ops/2026-09-24-subagent-model-config.md"
|
||
argv = ["with", "--task", request["task_id"], "--wait", "0"]
|
||
for res in resources:
|
||
argv.extend(("--resource", res))
|
||
argv.extend((
|
||
"--",
|
||
sys.executable, "-B", str(task_path / "scripts" / "pi_subagents.py"),
|
||
"run", "--request", str(req_path),
|
||
"--config", str(wt_config),
|
||
))
|
||
result = self.agentctl(*argv, cwd=task_path, check=False, env=env)
|
||
if check:
|
||
self.assertEqual(result.returncode, 0, result.stdout + "\n" + result.stderr)
|
||
return result
|
||
|
||
# --- presence / import ---
|
||
|
||
def test_00_executor_module_present(self):
|
||
self.assertTrue(SOURCE_EXEC.exists(), "scripts/pi_subagents.py must be implemented")
|
||
self.assertTrue(SOURCE_MCP.exists(), "scripts/mcp/pi-subagents-mcp.mjs must be implemented")
|
||
self.assertTrue(SOURCE_EXT.exists(), "scripts/pi-subagent-readonly.ts must be implemented")
|
||
|
||
# --- config parsing ---
|
||
|
||
def test_config_catalog_parses_known_pool_and_roles(self):
|
||
task = self.initialized_task()
|
||
result = self.cli("roles", "--config", str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task)
|
||
payload = self.last_json(result.stdout)
|
||
self.assertEqual(payload["config_revision"], "subagent-models-2026-09-24-r1")
|
||
self.assertEqual(set(payload["models"].keys()), set(KNOWN_ALIASES))
|
||
self.assertEqual(set(payload["roles"].keys()), set(KNOWN_ROLES))
|
||
self.assertFalse(payload["roles"]["auxiliary"]["enabled"])
|
||
self.assertEqual(payload["params"]["max_parallel_pi_tasks"], 2)
|
||
self.assertEqual(payload["params"]["implementation_executor"], "cursor_auto")
|
||
self.assertEqual(payload["params"]["automatic_fallback"], "none")
|
||
self.assertIn("config_sha256", payload)
|
||
self.assertEqual(payload["config_sha256"], sha256_text(
|
||
(task / ".doc/ops/2026-09-24-subagent-model-config.md").read_text(encoding="utf-8")))
|
||
|
||
def test_roles_does_not_invoke_pi(self):
|
||
task = self.initialized_task()
|
||
if self.argv_log.exists():
|
||
self.argv_log.unlink()
|
||
self.cli("roles", "--config", str(task / ".doc/ops/2026-09-24-subagent-model-config.md"), cwd=task)
|
||
self.assertFalse(self.argv_log.exists(), "roles must not spawn PI")
|
||
|
||
def test_config_rejects_unknown_alias_and_cycle(self):
|
||
task = self.initialized_task()
|
||
bad = (task / ".doc/ops/2026-09-24-subagent-model-config.md").read_text(encoding="utf-8")
|
||
bad = bad.replace("| research | 检索、代码定位、资料摘录、需求初稿 | true | qwen_flash | planning |",
|
||
"| research | 检索、代码定位、资料摘录、需求初稿 | true | not_a_model | planning |")
|
||
if "not_a_model" not in bad:
|
||
bad = bad.replace("| research | r | true | qwen_flash | planning | ro |",
|
||
"| research | r | true | not_a_model | planning | ro |")
|
||
(task / ".doc/ops/2026-09-24-subagent-model-config.md").write_text(bad, encoding="utf-8")
|
||
result = self.cli("config", "--config",
|
||
str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task, check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "UNKNOWN_ALIAS")
|
||
|
||
cyclic = BASE_CONFIG or self._minimal_config()
|
||
cyclic = cyclic.replace("| planning | 需求分析、方案制定、复杂问题综合分析 | true | qwen_max | none |",
|
||
"| planning | 需求分析、方案制定、复杂问题综合分析 | true | qwen_max | research |")
|
||
if "| planning | p | true | qwen_max | none | ro |" in cyclic:
|
||
cyclic = cyclic.replace("| planning | p | true | qwen_max | none | ro |",
|
||
"| planning | p | true | qwen_max | research | ro |")
|
||
(task / ".doc/ops/2026-09-24-subagent-model-config.md").write_text(cyclic, encoding="utf-8")
|
||
result = self.cli("config", "--config",
|
||
str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task, check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "UPGRADE_CYCLE")
|
||
|
||
def test_disabled_auxiliary_and_m3_critical_remap_denied(self):
|
||
task = self.initialized_task()
|
||
# auxiliary disabled by default
|
||
req = self.base_request(role_id="auxiliary", request_id="aux-1")
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "ROLE_DISABLED")
|
||
|
||
# remap code_review default model to minimax_aux
|
||
text = (task / ".doc/ops/2026-09-24-subagent-model-config.md").read_text(encoding="utf-8")
|
||
text = text.replace("| code_review | 代码审查与回归风险检查 | true | glm_full | none |",
|
||
"| code_review | 代码审查与回归风险检查 | true | minimax_aux | none |")
|
||
if "minimax_aux | none |" not in text.split("code_review")[1][:200]:
|
||
text = text.replace("| code_review | cr | true | glm_full | none | ro |",
|
||
"| code_review | cr | true | minimax_aux | none | ro |")
|
||
(task / ".doc/ops/2026-09-24-subagent-model-config.md").write_text(text, encoding="utf-8")
|
||
req = self.base_request(role_id="code_review", request_id="m3-deny",
|
||
goal="review", )
|
||
result = self.run_under_lease(task, req, check=False,
|
||
env_extra={"FAKE_PI_PROVIDER": "minimax-cn",
|
||
"FAKE_PI_MODEL": "MiniMax-M3"})
|
||
err = self.result_json(result)
|
||
self.assertIn(err["code"], ("ROLE_MODEL_FORBIDDEN", "MINIMAX_ROLE_FORBIDDEN"))
|
||
|
||
# --- lease / naked refusal ---
|
||
|
||
def test_naked_run_without_lease_refused(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request()
|
||
req_path = pathlib.Path(self.temp.name) / "naked.json"
|
||
req_path.write_text(json.dumps(req), encoding="utf-8")
|
||
result = self.cli(
|
||
"run", "--request", str(req_path),
|
||
"--config", str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task, check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "LEASE_REQUIRED")
|
||
|
||
def test_forged_lease_env_refused(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="forge-1")
|
||
req_path = pathlib.Path(self.temp.name) / "forge.json"
|
||
req_path.write_text(json.dumps(req), encoding="utf-8")
|
||
forged = self.env({
|
||
"AGENTCTL_RUN_ID": "not-a-real-run",
|
||
"AGENTCTL_TASK_ID": "alpha",
|
||
"AGENTCTL_GENERATION": "1",
|
||
"AGENTCTL_EPOCH": "fake-epoch",
|
||
"AGENTCTL_FENCE": "0",
|
||
})
|
||
result = self.cli(
|
||
"run", "--request", str(req_path),
|
||
"--config", str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task, check=False, env=forged)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
err = self.result_json(result)
|
||
self.assertIn(err["code"], ("LEASE_REQUIRED", "STALE_GENERATION"))
|
||
|
||
def test_scope_refusal_outside_report_path(self):
|
||
task = self.initialized_task(scopes=("src",)) # no report scope
|
||
req = self.base_request(request_id="noscope")
|
||
result = self.run_under_lease(
|
||
task, req,
|
||
resources=["path:.doc/test-reports/assets/pi-subagents/alpha/noscope"],
|
||
check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
|
||
# --- input validation ---
|
||
|
||
def test_input_path_traversal_symlink_secret_refusal(self):
|
||
task = self.initialized_task()
|
||
for bad, code in (
|
||
(["../etc/passwd"], "PATH_OUT_OF_SCOPE"),
|
||
([".env"], "PATH_OUT_OF_SCOPE"),
|
||
(["src/../.git/config"], "PATH_OUT_OF_SCOPE"),
|
||
):
|
||
req = self.base_request(request_id="bad-" + code.lower() + str(hash(tuple(bad)) % 1000),
|
||
input_files=bad)
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], code, err)
|
||
|
||
# symlink refusal
|
||
link = task / "src" / "link.py"
|
||
try:
|
||
link.symlink_to(task / "src" / "sample.py")
|
||
except OSError:
|
||
self.skipTest("symlink unsupported")
|
||
self.git("add", "src/link.py", cwd=task)
|
||
# uncommitted symlink should be refused; commit it then refuse as symlink
|
||
try:
|
||
self.git("commit", "-m", "symlink", cwd=task)
|
||
except subprocess.CalledProcessError:
|
||
pass
|
||
sha = self.git("rev-parse", "HEAD", cwd=task)
|
||
req = self.base_request(request_id="symlink-1", input_files=["src/link.py"], source_sha=sha)
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertIn(err["code"], ("SYMLINK_REFUSED", "PATH_OUT_OF_SCOPE", "INPUT_NOT_REGULAR"))
|
||
|
||
# --- successful run / argv flags / receipts ---
|
||
|
||
def test_run_emits_exact_pi_flags_and_receipt(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="ok-1")
|
||
result = self.run_under_lease(task, req)
|
||
payload = self.last_json(result.stdout)
|
||
self.assertEqual(payload["run_status"], "succeeded")
|
||
# Non-test roles: model completion is pending review, not auto pass.
|
||
self.assertEqual(payload["verdict"], "not_applicable")
|
||
self.assertEqual(payload.get("review_state"), "pending")
|
||
self.assertNotEqual(payload["verdict"], payload["run_status"])
|
||
argv = json.loads(self.argv_log.read_text(encoding="utf-8"))
|
||
joined = " ".join(argv)
|
||
for flag in (
|
||
"--no-session", "--mode", "json", "--print", "--offline",
|
||
"--no-skills", "--no-prompt-templates", "--no-themes",
|
||
"--no-context-files", "--no-approve", "--no-extensions",
|
||
"--provider", "qwen-token-plan-cn", "--model", "qwen3.8-flash",
|
||
"--tools",
|
||
):
|
||
self.assertIn(flag, argv if flag.startswith("--") and flag != "--tools" else joined)
|
||
self.assertIn("read,grep,find,ls", joined.replace(" ", ""))
|
||
self.assertTrue(any(a == "-e" or a == "--extension" for a in argv))
|
||
rdir = self.report_dir("alpha", "ok-1", task)
|
||
self.assertTrue((rdir / "receipt.json").is_file())
|
||
self.assertTrue((rdir / "report.md").is_file())
|
||
receipt = json.loads((rdir / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertEqual(receipt["configured_provider"], "qwen-token-plan-cn")
|
||
self.assertEqual(receipt["configured_model"], "qwen3.8-flash")
|
||
self.assertEqual(receipt["source_sha"], self.source_sha)
|
||
self.assertIn("config_sha256", receipt)
|
||
self.assertIn("input_files", receipt)
|
||
mode = oct((rdir / "receipt.json").stat().st_mode)[-3:]
|
||
self.assertIn(mode, ("600", "640", "644")) # prefer 600; accept umask variance in CI
|
||
|
||
def test_extension_outside_model_snapshot_inventory(self):
|
||
"""Model snapshot must contain only declared inputs; -e guard lives outside it."""
|
||
task = self.initialized_task()
|
||
probe = pathlib.Path(self.temp.name) / "snap-probe.json"
|
||
req = self.base_request(request_id="snap-clean-1")
|
||
self.run_under_lease(
|
||
task, req,
|
||
env_extra={"FAKE_PI_SNAP_PROBE": str(probe)})
|
||
self.assertTrue(probe.is_file(), "fake PI must record snapshot probe")
|
||
data = json.loads(probe.read_text(encoding="utf-8"))
|
||
declared = list(req["input_files"])
|
||
self.assertEqual(sorted(data["inventory"]), sorted(declared), data)
|
||
self.assertFalse(
|
||
any(p == "pi-subagent-readonly.ts"
|
||
or p.endswith("/pi-subagent-readonly.ts")
|
||
or p.startswith(".pi-extensions/")
|
||
for p in data["inventory"]),
|
||
data)
|
||
self.assertTrue(data.get("extension"), data)
|
||
self.assertTrue(data.get("extension_exists"),
|
||
"extension must exist on disk during PI run")
|
||
self.assertFalse(data.get("extension_inside_snapshot"), data)
|
||
snap = pathlib.Path(data["snapshot_root"]).resolve()
|
||
self.assertEqual(pathlib.Path(data["cwd"]).resolve(), snap, data)
|
||
ext = pathlib.Path(data["extension"]).resolve()
|
||
try:
|
||
ext.relative_to(snap)
|
||
self.fail(f"extension unexpectedly under snapshot: {ext}")
|
||
except ValueError:
|
||
pass
|
||
|
||
def test_model_mismatch_fails(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="mm-1")
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_PROVIDER": "other", "FAKE_PI_MODEL": "wrong"})
|
||
# may succeed process but receipt run_status failed
|
||
rdir = self.report_dir("alpha", "mm-1", task)
|
||
if (rdir / "receipt.json").exists():
|
||
receipt = json.loads((rdir / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertEqual(receipt["run_status"], "failed")
|
||
self.assertNotEqual(receipt.get("verdict"), "pass")
|
||
else:
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "MODEL_MISMATCH")
|
||
|
||
def test_malformed_and_empty_outputs_fail(self):
|
||
task = self.initialized_task()
|
||
for mode, rid in (("empty_text", "empty-1"), ("no_agent_end", "noend-1"),
|
||
("stop_error", "sterr-1"), ("thinking_only", "think-1"),
|
||
("nonzero", "nz-1"), ("will_retry", "retry-1"),
|
||
("tool_use_stop", "toolstop-1"), ("no_settled", "nosettle-1"),
|
||
("activity_after_settled", "after-1")):
|
||
req = self.base_request(request_id=rid)
|
||
self.run_under_lease(task, req, check=False, env_extra={"FAKE_PI_MODE": mode})
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", rid, task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertIn(receipt["run_status"], ("failed", "unknown"))
|
||
self.assertNotEqual(receipt.get("verdict"), "pass")
|
||
|
||
# --- idempotency ---
|
||
|
||
def test_idempotent_replay_and_digest_mismatch(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="idem-1")
|
||
self.run_under_lease(task, req)
|
||
first = json.loads(
|
||
(self.report_dir("alpha", "idem-1", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
if self.argv_log.exists():
|
||
self.argv_log.unlink()
|
||
result = self.run_under_lease(task, req)
|
||
payload = self.last_json(result.stdout)
|
||
self.assertTrue(payload.get("replay") or payload.get("run_status") == first["run_status"])
|
||
self.assertFalse(self.argv_log.exists(), "replay must not re-invoke PI")
|
||
|
||
req2 = dict(req)
|
||
req2["goal"] = "different goal same request_id"
|
||
result = self.run_under_lease(task, req2, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "REQUEST_DIGEST_MISMATCH")
|
||
|
||
def test_config_reload_snapshot_on_dispatch(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="snap-1")
|
||
self.run_under_lease(task, req)
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", "snap-1", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
old_sha = receipt["config_sha256"]
|
||
cfg = task / ".doc/ops/2026-09-24-subagent-model-config.md"
|
||
text = cfg.read_text(encoding="utf-8")
|
||
text = text.replace("config_revision:`subagent-models-2026-09-24-r1`",
|
||
"config_revision:`subagent-models-2026-09-24-r2`")
|
||
cfg.write_text(text + "\n<!-- touch -->\n", encoding="utf-8")
|
||
req2 = self.base_request(request_id="snap-2")
|
||
self.run_under_lease(task, req2)
|
||
receipt2 = json.loads(
|
||
(self.report_dir("alpha", "snap-2", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertNotEqual(old_sha, receipt2["config_sha256"])
|
||
self.assertEqual(receipt2["config_revision"], "subagent-models-2026-09-24-r2")
|
||
|
||
# --- test_run governance ---
|
||
|
||
def test_test_run_requires_approved_commands_and_records_failure(self):
|
||
task = self.initialized_task()
|
||
# other roles reject approved_commands
|
||
req = self.base_request(request_id="badcmd", role_id="research",
|
||
approved_commands=[["true"]])
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "APPROVED_COMMANDS_NOT_ALLOWED")
|
||
|
||
# test_run without plan/commands fails
|
||
req = self.base_request(request_id="tr-miss", role_id="test_run",
|
||
goal="run tests",
|
||
approved_commands=None)
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_PROVIDER": "zai-coding-cn", "FAKE_PI_MODEL": "glm-5.3-flash"})
|
||
err = self.result_json(result)
|
||
self.assertIn(err["code"], ("APPROVED_COMMANDS_REQUIRED", "PLAN_DIGEST_REQUIRED"))
|
||
|
||
# failing approved command cannot be promoted by model success text
|
||
plan = {"commands": [["false"]], "purpose": "unit"}
|
||
plan_digest = sha256_text(json.dumps(plan, sort_keys=True))
|
||
req = self.base_request(
|
||
request_id="tr-fail",
|
||
role_id="test_run",
|
||
goal="execute approved plan",
|
||
approved_commands=[["false"]],
|
||
plan_digest=plan_digest,
|
||
authorization_ref="user:test-plan",
|
||
)
|
||
self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={
|
||
"FAKE_PI_PROVIDER": "zai-coding-cn",
|
||
"FAKE_PI_MODEL": "glm-5.3-flash",
|
||
"FAKE_PI_TEXT": "All tests passed!",
|
||
})
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", "tr-fail", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertNotEqual(receipt["verdict"], "pass")
|
||
self.assertTrue(receipt.get("test_outcomes"))
|
||
self.assertNotEqual(receipt["test_outcomes"][0].get("exit_code"), 0)
|
||
|
||
# shared env ops unsupported
|
||
req = self.base_request(
|
||
request_id="tr-env",
|
||
role_id="test_run",
|
||
goal="deploy",
|
||
approved_commands=[["true"]],
|
||
plan_digest=sha256_text(json.dumps({"commands": [["true"]]}, sort_keys=True)),
|
||
shared_env_ops=[{"op": "restart", "target": "dev"}],
|
||
authorization_ref="user:test-plan",
|
||
)
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_PROVIDER": "zai-coding-cn", "FAKE_PI_MODEL": "glm-5.3-flash"})
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "SHARED_ENV_UNSUPPORTED")
|
||
|
||
# --- timeout / process cleanup ---
|
||
|
||
def test_timeout_kills_process_group(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="to-1", timeout_seconds=1)
|
||
marker = pathlib.Path(self.temp.name) / "child-alive"
|
||
# wrap fake pi to spawn a child that touches marker while hanging
|
||
wrapper = pathlib.Path(self.temp.name) / "hang-pi"
|
||
wrapper.write_text(
|
||
"#!/usr/bin/env python3\n"
|
||
"import os,sys,time,subprocess,json\n"
|
||
f"log={str(self.argv_log)!r}\n"
|
||
"open(log,'w').write(json.dumps(sys.argv[1:]))\n"
|
||
f"marker={str(marker)!r}\n"
|
||
"child=subprocess.Popen([sys.executable,'-c',"
|
||
"'import time,pathlib,os; pathlib.Path(os.environ[\"M\"]).write_text(\"1\"); time.sleep(30)'],"
|
||
"env={**os.environ,'M':marker}, start_new_session=False)\n"
|
||
"time.sleep(30)\n",
|
||
encoding="utf-8")
|
||
wrapper.chmod(0o755)
|
||
started = time.monotonic()
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"PI_SUBAGENT_PI_BIN": str(wrapper)})
|
||
elapsed = time.monotonic() - started
|
||
self.assertLess(elapsed, 15)
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", "to-1", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertIn(receipt["run_status"], ("failed", "cancelled", "unknown"))
|
||
# give a moment; child should be reaped (marker may exist but process dead)
|
||
time.sleep(0.5)
|
||
# best-effort: no orphan sleep 30 from wrapper still running for this test uid/cwd
|
||
self.assertIsNotNone(result)
|
||
|
||
# --- concurrency ---
|
||
|
||
def test_global_concurrency_limit(self):
|
||
task = self.initialized_task()
|
||
blockers = []
|
||
|
||
def hold(rid):
|
||
req = self.base_request(request_id=rid, timeout_seconds=8)
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_MODE": "hang"})
|
||
blockers.append(result)
|
||
|
||
threads = []
|
||
for rid in ("c1", "c2"):
|
||
t = threading.Thread(target=hold, args=(rid,))
|
||
t.start()
|
||
threads.append(t)
|
||
# Wait until both slots are held (receipts show running)
|
||
deadline = time.monotonic() + 10
|
||
while time.monotonic() < deadline:
|
||
r1 = self.report_dir("alpha", "c1", task) / "receipt.json"
|
||
r2 = self.report_dir("alpha", "c2", task) / "receipt.json"
|
||
if r1.is_file() and r2.is_file():
|
||
break
|
||
time.sleep(0.1)
|
||
req = self.base_request(request_id="c3", timeout_seconds=3)
|
||
result = self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_MODE": "ok"})
|
||
err_text = result.stderr + result.stdout
|
||
self.assertNotEqual(result.returncode, 0, err_text)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "CONCURRENCY_LIMIT")
|
||
# Do not pkill: broad patterns can match the harness itself. Hang runs
|
||
# are bounded by timeout_seconds and reaped by the executor.
|
||
for t in threads:
|
||
t.join(timeout=20)
|
||
|
||
# --- additional regressions ---
|
||
|
||
def test_source_sha_rejects_symbolic_refs(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="sha-head", source_sha="HEAD")
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "INVALID_SOURCE_SHA")
|
||
|
||
def test_corrupt_receipt_fail_closed(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="corrupt-1")
|
||
self.run_under_lease(task, req)
|
||
rdir = self.report_dir("alpha", "corrupt-1", task)
|
||
(rdir / "receipt.json").write_text("{not-json", encoding="utf-8")
|
||
result = self.run_under_lease(task, req, check=False)
|
||
err = self.result_json(result)
|
||
self.assertEqual(err["code"], "RECEIPT_CORRUPT")
|
||
|
||
def test_replay_keeps_old_config_after_disable(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="replay-cfg")
|
||
self.run_under_lease(task, req)
|
||
first = json.loads(
|
||
(self.report_dir("alpha", "replay-cfg", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
cfg = task / ".doc/ops/2026-09-24-subagent-model-config.md"
|
||
text = cfg.read_text(encoding="utf-8")
|
||
if "| research | r | true |" in text:
|
||
text = text.replace("| research | r | true |", "| research | r | false |")
|
||
else:
|
||
text = text.replace(
|
||
"| research | 检索、代码定位、资料摘录、需求初稿 | true |",
|
||
"| research | 检索、代码定位、资料摘录、需求初稿 | false |")
|
||
cfg.write_text(text, encoding="utf-8")
|
||
if self.argv_log.exists():
|
||
self.argv_log.unlink()
|
||
result = self.run_under_lease(task, req)
|
||
payload = self.last_json(result.stdout)
|
||
self.assertTrue(payload.get("replay"))
|
||
self.assertEqual(payload["config_sha256"], first["config_sha256"])
|
||
self.assertFalse(self.argv_log.exists())
|
||
|
||
def test_readonly_guard_rejects_at_prefix(self):
|
||
"""Exercise extension path policy via Node strip-types if available."""
|
||
if not SOURCE_EXT.exists():
|
||
self.fail("extension missing")
|
||
outside = pathlib.Path(self.temp.name) / "outside.txt"
|
||
outside.write_text("SECRET", encoding="utf-8")
|
||
snap = pathlib.Path(self.temp.name) / "snap"
|
||
snap.mkdir()
|
||
(snap / "ok.txt").write_text("hi", encoding="utf-8")
|
||
(snap / "stray.txt").write_text("nope", encoding="utf-8")
|
||
sub = snap / "sub"
|
||
sub.mkdir()
|
||
(sub / "nested.txt").write_text("x", encoding="utf-8")
|
||
try:
|
||
(snap / "evil.link").symlink_to(snap / "ok.txt")
|
||
except OSError:
|
||
pass
|
||
probe = pathlib.Path(self.temp.name) / "guard-probe.mjs"
|
||
probe.write_text(
|
||
"import { pathToFileURL } from 'node:url';\n"
|
||
"import fs from 'node:fs';\n"
|
||
f"const extPath = {str(SOURCE_EXT.resolve())!r};\n"
|
||
f"const root = {str(snap.resolve())!r};\n"
|
||
f"const outside = {str(outside.resolve())!r};\n"
|
||
"process.env.PI_SUBAGENT_SNAPSHOT_ROOT = root;\n"
|
||
"process.env.PI_SUBAGENT_ALLOWED_FILES = JSON.stringify(['ok.txt']);\n"
|
||
"const mod = await import(pathToFileURL(extPath).href);\n"
|
||
"const handlers = [];\n"
|
||
"const pi = { on(ev, fn) { handlers.push(fn); } };\n"
|
||
"(mod.default || mod)(pi);\n"
|
||
"const handler = handlers[0];\n"
|
||
"const cases = [\n"
|
||
" { toolName: 'read', input: { path: '@' + outside } },\n"
|
||
" { toolName: 'read', input: { path: '~/outside.txt' } },\n"
|
||
" { toolName: 'read', input: { path: outside } },\n"
|
||
" { toolName: 'read', input: { path: 'ok.txt' } },\n"
|
||
" { toolName: 'ls', input: {} },\n" # default '.'
|
||
" { toolName: 'grep', input: { path: '.' } },\n"
|
||
" { toolName: 'read', input: { path: 'stray.txt' } },\n"
|
||
" { toolName: 'read', input: { path: 'sub' } },\n" # dir with non-allowlisted nested
|
||
" { toolName: 'read', input: { file: 'ok.txt' } },\n" # unsupported carrier
|
||
" { toolName: 'read', input: { path: 'evil.link' } },\n"
|
||
"];\n"
|
||
"const out = [];\n"
|
||
"for (const c of cases) {\n"
|
||
" const r = await handler(c, {});\n"
|
||
" out.push({ tool: c.toolName, input: c.input, blocked: !!(r && r.block), reason: r && r.reason,\n"
|
||
" pathCanon: c.input && c.input.path });\n"
|
||
"}\n"
|
||
"process.stdout.write(JSON.stringify(out));\n",
|
||
encoding="utf-8")
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", str(probe)],
|
||
capture_output=True, text=True, timeout=30)
|
||
if result.returncode != 0 and "strip-types" in (result.stderr or ""):
|
||
self.skipTest("node --experimental-strip-types unavailable")
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
rows = json.loads(result.stdout)
|
||
self.assertTrue(rows[0]["blocked"], rows) # @
|
||
self.assertTrue(rows[1]["blocked"], rows) # ~
|
||
self.assertTrue(rows[2]["blocked"], rows) # absolute outside
|
||
self.assertFalse(rows[3]["blocked"], rows) # ok.txt
|
||
self.assertTrue(rows[3]["pathCanon"].startswith(str(snap.resolve())), rows)
|
||
self.assertTrue(rows[4]["blocked"], rows) # ls default . has stray.txt
|
||
self.assertTrue(rows[5]["blocked"], rows) # grep .
|
||
self.assertTrue(rows[6]["blocked"], rows) # stray file
|
||
self.assertTrue(rows[7]["blocked"], rows) # dir with nested non-allow
|
||
self.assertTrue(rows[8]["blocked"], rows) # file carrier
|
||
self.assertTrue(rows[9]["blocked"], rows) # symlink
|
||
|
||
def test_readonly_guard_allows_exact_root_listing(self):
|
||
"""Exact-root ls/grep must pass when snapshot inventory is allowlisted-only."""
|
||
if not SOURCE_EXT.exists():
|
||
self.fail("extension missing")
|
||
snap = pathlib.Path(self.temp.name) / "snap-exact-root"
|
||
snap.mkdir()
|
||
(snap / "sample.txt").write_text("ok", encoding="utf-8")
|
||
nested = snap / "nested"
|
||
nested.mkdir()
|
||
(nested / "inner.txt").write_text("in", encoding="utf-8")
|
||
dirty = pathlib.Path(self.temp.name) / "snap-dirty"
|
||
dirty.mkdir()
|
||
(dirty / "sample.txt").write_text("ok", encoding="utf-8")
|
||
(dirty / "stray.txt").write_text("nope", encoding="utf-8")
|
||
try:
|
||
(dirty / "evil.link").symlink_to(dirty / "sample.txt")
|
||
except OSError:
|
||
pass
|
||
empty = pathlib.Path(self.temp.name) / "snap-empty"
|
||
empty.mkdir()
|
||
probe = pathlib.Path(self.temp.name) / "guard-exact-root.mjs"
|
||
probe.write_text(
|
||
"import { pathToFileURL } from 'node:url';\n"
|
||
f"const extPath = {str(SOURCE_EXT.resolve())!r};\n"
|
||
f"const clean = {str(snap.resolve())!r};\n"
|
||
f"const dirty = {str(dirty.resolve())!r};\n"
|
||
f"const empty = {str(empty.resolve())!r};\n"
|
||
"const mod = await import(pathToFileURL(extPath).href);\n"
|
||
"async function run(root, allow, cases) {\n"
|
||
" process.env.PI_SUBAGENT_SNAPSHOT_ROOT = root;\n"
|
||
" process.env.PI_SUBAGENT_ALLOWED_FILES = JSON.stringify(allow);\n"
|
||
" const handlers = [];\n"
|
||
" const pi = { on(ev, fn) { handlers.push(fn); } };\n"
|
||
" (mod.default || mod)(pi);\n"
|
||
" const handler = handlers[0];\n"
|
||
" const out = [];\n"
|
||
" for (const c of cases) {\n"
|
||
" const input = c.input ? { ...c.input } : {};\n"
|
||
" const r = await handler({ toolName: c.toolName, input }, {});\n"
|
||
" out.push({ tag: c.tag, blocked: !!(r && r.block), reason: r && r.reason });\n"
|
||
" }\n"
|
||
" return out;\n"
|
||
"}\n"
|
||
"const cleanAllow = ['sample.txt', 'nested/inner.txt'];\n"
|
||
"const rows = [];\n"
|
||
"rows.push(...await run(clean, cleanAllow, [\n"
|
||
" { tag: 'ls_dot', toolName: 'ls', input: { path: '.' } },\n"
|
||
" { tag: 'ls_omit', toolName: 'ls', input: {} },\n"
|
||
" { tag: 'grep_default', toolName: 'grep', input: {} },\n"
|
||
" { tag: 'ls_nested', toolName: 'ls', input: { path: 'nested' } },\n"
|
||
"]));\n"
|
||
"rows.push(...await run(dirty, ['sample.txt'], [\n"
|
||
" { tag: 'stray_file', toolName: 'read', input: { path: 'stray.txt' } },\n"
|
||
" { tag: 'symlink', toolName: 'read', input: { path: 'evil.link' } },\n"
|
||
" { tag: 'ls_dirty_root', toolName: 'ls', input: { path: '.' } },\n"
|
||
"]));\n"
|
||
"rows.push(...await run(empty, ['sample.txt'], [\n"
|
||
" { tag: 'empty_root_ls', toolName: 'ls', input: { path: '.' } },\n"
|
||
"]));\n"
|
||
"process.stdout.write(JSON.stringify(rows));\n",
|
||
encoding="utf-8")
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", str(probe)],
|
||
capture_output=True, text=True, timeout=30)
|
||
if result.returncode != 0 and "strip-types" in (result.stderr or ""):
|
||
self.skipTest("node --experimental-strip-types unavailable")
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
rows = {r["tag"]: r for r in json.loads(result.stdout)}
|
||
self.assertFalse(rows["ls_dot"]["blocked"], rows)
|
||
self.assertFalse(rows["ls_omit"]["blocked"], rows)
|
||
self.assertFalse(rows["grep_default"]["blocked"], rows)
|
||
self.assertFalse(rows["ls_nested"]["blocked"], rows)
|
||
self.assertTrue(rows["stray_file"]["blocked"], rows)
|
||
self.assertTrue(rows["symlink"]["blocked"], rows)
|
||
self.assertTrue(rows["ls_dirty_root"]["blocked"], rows)
|
||
self.assertFalse(rows["empty_root_ls"]["blocked"], rows)
|
||
|
||
def test_output_cap_on_stdout_flood(self):
|
||
task = self.initialized_task()
|
||
flood = pathlib.Path(self.temp.name) / "flood-pi"
|
||
flood.write_text(
|
||
"#!/usr/bin/env python3\n"
|
||
"import sys\n"
|
||
"sys.stdout.write('x' * (5 * 1024 * 1024))\n"
|
||
"sys.stdout.flush()\n",
|
||
encoding="utf-8")
|
||
flood.chmod(0o755)
|
||
req = self.base_request(request_id="flood-1", timeout_seconds=10)
|
||
self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"PI_SUBAGENT_PI_BIN": str(flood)})
|
||
rdir = self.report_dir("alpha", "flood-1", task)
|
||
receipt = json.loads((rdir / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertEqual(receipt["run_status"], "failed")
|
||
codes = {e.get("code") for e in receipt.get("errors") or []}
|
||
self.assertTrue(
|
||
"OUTPUT_CAP" in codes or "PI_PARSE" in codes or "PI_NONZERO" in codes
|
||
or "MALFORMED_JSON" in codes,
|
||
receipt)
|
||
|
||
def test_leader_exit_reaps_orphan_child(self):
|
||
task = self.initialized_task()
|
||
marker = pathlib.Path(self.temp.name) / "orphan-child-alive"
|
||
leader = pathlib.Path(self.temp.name) / "leader-exit-pi"
|
||
leader.write_text(
|
||
"#!/usr/bin/env python3\n"
|
||
"import os, sys, subprocess, time, pathlib\n"
|
||
f"marker={str(marker)!r}\n"
|
||
"child=subprocess.Popen([sys.executable,'-c',"
|
||
"'import time,pathlib,os; pathlib.Path(os.environ[\"M\"]).write_text(str(os.getpid())); time.sleep(30)'],"
|
||
"env={**os.environ,'M':marker}, start_new_session=False)\n"
|
||
"print(child.pid, flush=True)\n"
|
||
"time.sleep(0.2)\n"
|
||
"sys.exit(0)\n",
|
||
encoding="utf-8")
|
||
leader.chmod(0o755)
|
||
req = self.base_request(request_id="orphan-1", timeout_seconds=8)
|
||
self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"PI_SUBAGENT_PI_BIN": str(leader)})
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", "orphan-1", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertEqual(receipt["run_status"], "failed", receipt)
|
||
codes = {e.get("code") for e in receipt.get("errors") or []}
|
||
self.assertIn("ORPHAN_CHILD", codes, receipt)
|
||
time.sleep(0.3)
|
||
if marker.is_file():
|
||
try:
|
||
cpid = int(marker.read_text().strip())
|
||
except ValueError:
|
||
cpid = None
|
||
if cpid:
|
||
# child must be gone; never kill arbitrary recycled PID without identity
|
||
alive = pathlib.Path(f"/proc/{cpid}").exists()
|
||
self.assertFalse(alive, f"orphan child {cpid} still alive")
|
||
|
||
def test_orphan_result_is_readonly(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="orphan-ro")
|
||
self.run_under_lease(task, req)
|
||
rdir = self.report_dir("alpha", "orphan-ro", task)
|
||
receipt_path = rdir / "receipt.json"
|
||
data = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||
data["run_status"] = "running"
|
||
data["supervisor_pid"] = 1
|
||
data["supervisor_start"] = 0
|
||
data["supervisor_boot_id"] = "not-the-real-boot"
|
||
receipt_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||
before = hashlib.sha256(receipt_path.read_bytes()).hexdigest()
|
||
result = self.cli(
|
||
"result", "--task", "alpha", "--request-id", "orphan-ro",
|
||
cwd=task, check=False)
|
||
payload = self.result_json(result)
|
||
self.assertEqual(payload.get("run_status"), "unknown")
|
||
after = hashlib.sha256(receipt_path.read_bytes()).hexdigest()
|
||
self.assertEqual(before, after, "result must not mutate receipt on disk")
|
||
|
||
def test_approved_commands_do_not_mutate_model_snapshot(self):
|
||
task = self.initialized_task()
|
||
plan = {"commands": [["python3", "-c",
|
||
"open('src/sample.py','w').write('MUTATED'); "
|
||
"import os; os.symlink('src/sample.py','src/evil.link')"]],
|
||
"purpose": "mutate"}
|
||
plan_digest = sha256_text(json.dumps(plan, sort_keys=True))
|
||
req = self.base_request(
|
||
request_id="snap-iso",
|
||
role_id="test_run",
|
||
goal="mutate snapshot",
|
||
approved_commands=plan["commands"],
|
||
plan_digest=plan_digest,
|
||
authorization_ref="user:test-plan",
|
||
)
|
||
self.run_under_lease(
|
||
task, req, check=False,
|
||
env_extra={"FAKE_PI_PROVIDER": "zai-coding-cn", "FAKE_PI_MODEL": "glm-5.3-flash"})
|
||
receipt = json.loads(
|
||
(self.report_dir("alpha", "snap-iso", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
# Model input hash must remain original sample.py content
|
||
self.assertEqual(receipt["input_files"][0]["sha256"],
|
||
sha256_text("print('hello')\n"))
|
||
self.assertIn("test_outcomes", receipt)
|
||
# Evidence files persisted under report dir
|
||
ev = receipt["test_outcomes"][0]
|
||
self.assertTrue(ev.get("stdout_path") or ev.get("stderr_path"))
|
||
if ev.get("stdout_path"):
|
||
self.assertTrue((self.report_dir("alpha", "snap-iso", task) / ev["stdout_path"]).is_file())
|
||
|
||
def test_failed_replay_stays_failure(self):
|
||
task = self.initialized_task()
|
||
req = self.base_request(request_id="fail-replay")
|
||
self.run_under_lease(
|
||
task, req, check=False, env_extra={"FAKE_PI_MODE": "nonzero"})
|
||
first = json.loads(
|
||
(self.report_dir("alpha", "fail-replay", task) / "receipt.json").read_text(encoding="utf-8"))
|
||
self.assertEqual(first["run_status"], "failed")
|
||
self.assertFalse(first.get("ok"))
|
||
result = self.run_under_lease(task, req, check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
payload = self.result_json(result)
|
||
self.assertTrue(payload.get("replay"))
|
||
self.assertEqual(payload.get("run_status"), "failed")
|
||
self.assertFalse(payload.get("ok"))
|
||
|
||
def test_scrub_env_drops_pi_dir_for_tests(self):
|
||
# Import executor scrub directly from source
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location(
|
||
"pi_subagents_under_test", SOURCE_EXEC)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
# agentctl must be importable
|
||
sys.path.insert(0, str(SOURCE_EXEC.parent))
|
||
spec.loader.exec_module(mod)
|
||
leaked = mod.scrub_env({
|
||
"PATH": "/usr/bin",
|
||
"PI_CODING_AGENT_DIR": "/tmp/real-auth",
|
||
"PI_SUBAGENT_SNAPSHOT_ROOT": "/tmp/snap",
|
||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||
"CURSOR_API_KEY": "secret",
|
||
}, for_pi=False)
|
||
self.assertNotIn("PI_CODING_AGENT_DIR", leaked)
|
||
self.assertNotIn("PI_SUBAGENT_SNAPSHOT_ROOT", leaked)
|
||
self.assertNotIn("XDG_RUNTIME_DIR", leaked)
|
||
self.assertNotIn("CURSOR_API_KEY", leaked)
|
||
|
||
# --- crash leaves unknown ---
|
||
|
||
def test_crash_before_receipt_terminal_is_unknown_on_status(self):
|
||
task = self.initialized_task()
|
||
# Simulate: write running receipt then crash without finalize by using mode crash after
|
||
# The executor should persist running first; crash mid leaves unknown when queried.
|
||
req = self.base_request(request_id="crash-1")
|
||
self.run_under_lease(
|
||
task, req, check=False, env_extra={"FAKE_PI_MODE": "crash"})
|
||
result = self.cli(
|
||
"status", "--task", "alpha", "--request-id", "crash-1",
|
||
"--config", str(task / ".doc/ops/2026-09-24-subagent-model-config.md"),
|
||
cwd=task, check=False)
|
||
payload = self.result_json(result)
|
||
self.assertIn(payload.get("run_status"), ("failed", "unknown"))
|
||
|
||
# --- MCP ---
|
||
|
||
def _mcp_stderr_preview(self, proc, limit=4000):
|
||
"""Non-blocking stderr peek — never block-read a live process pipe."""
|
||
if not proc.stderr:
|
||
return ""
|
||
try:
|
||
fd = proc.stderr.fileno()
|
||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||
chunk = proc.stderr.read(limit)
|
||
return chunk or ""
|
||
except Exception as exc:
|
||
return f"<stderr unavailable: {exc}>"
|
||
|
||
def _mcp_recv_line(self, proc, timeout=15.0):
|
||
"""Bounded NDJSON line receive from MCP stdout."""
|
||
fd = proc.stdout.fileno()
|
||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||
sel = selectors.DefaultSelector()
|
||
sel.register(proc.stdout, selectors.EVENT_READ)
|
||
buf = ""
|
||
deadline = time.monotonic() + timeout
|
||
try:
|
||
while time.monotonic() < deadline:
|
||
remaining = max(0.01, deadline - time.monotonic())
|
||
events = sel.select(min(0.5, remaining))
|
||
if events:
|
||
try:
|
||
chunk = proc.stdout.read(8192)
|
||
except BlockingIOError:
|
||
chunk = ""
|
||
if chunk:
|
||
buf += chunk
|
||
if "\n" in buf:
|
||
line, _rest = buf.split("\n", 1)
|
||
return line
|
||
elif proc.poll() is not None:
|
||
break
|
||
elif proc.poll() is not None:
|
||
try:
|
||
chunk = proc.stdout.read() or ""
|
||
buf += chunk
|
||
except Exception:
|
||
pass
|
||
if "\n" in buf:
|
||
return buf.split("\n", 1)[0]
|
||
break
|
||
finally:
|
||
sel.close()
|
||
err = self._mcp_stderr_preview(proc)
|
||
self.fail(f"MCP RPC timeout/no line; stderr_preview={err!r} buf={buf[:200]!r}")
|
||
|
||
def test_mcp_initialize_list_run_result(self):
|
||
if not (self.root / "scripts" / "mcp" / "pi-subagents-mcp.mjs").exists():
|
||
self.fail("MCP server missing")
|
||
task = self.initialized_task()
|
||
mcp = self.root / "scripts" / "mcp" / "pi-subagents-mcp.mjs"
|
||
env = self.env({"AGENTCTL_REPO": str(self.root)})
|
||
env["PI_SUBAGENT_PI_BIN"] = str(self.fake_pi)
|
||
proc = subprocess.Popen(
|
||
["node", str(mcp)],
|
||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
text=True, cwd=str(self.root), env=env)
|
||
|
||
def rpc(msg, timeout=20.0):
|
||
if "id" not in msg and str(msg.get("method", "")).startswith("notifications/"):
|
||
proc.stdin.write(json.dumps(msg) + "\n")
|
||
proc.stdin.flush()
|
||
return None
|
||
proc.stdin.write(json.dumps(msg) + "\n")
|
||
proc.stdin.flush()
|
||
line = self._mcp_recv_line(proc, timeout=timeout)
|
||
self.assertTrue(line.strip(), f"empty MCP line; stderr={self._mcp_stderr_preview(proc)!r}")
|
||
return json.loads(line)
|
||
|
||
try:
|
||
init = rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||
"clientInfo": {"name": "test", "version": "0"}}})
|
||
self.assertEqual(init["result"]["serverInfo"]["name"], "pi-subagents")
|
||
rpc({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||
tools = rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||
names = {t["name"] for t in tools["result"]["tools"]}
|
||
self.assertEqual(names, {"pi_subagent_roles", "pi_subagent_run", "pi_subagent_result"})
|
||
roles = rpc({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_roles",
|
||
"arguments": {"worktree": str(task), "task_id": "alpha"}}})
|
||
self.assertFalse(roles["result"].get("isError"))
|
||
body = json.loads(roles["result"]["content"][0]["text"])
|
||
self.assertIn("roles", body)
|
||
run = rpc({"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_run", "arguments": {
|
||
"worktree": str(task),
|
||
"task_id": "alpha",
|
||
"request": self.base_request(request_id="mcp-1"),
|
||
}}})
|
||
self.assertIn("result", run)
|
||
self.assertFalse(run["result"].get("isError"), run["result"])
|
||
deadline = time.monotonic() + 25
|
||
result_body = None
|
||
while time.monotonic() < deadline:
|
||
got = rpc({"jsonrpc": "2.0", "id": 5, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_result", "arguments": {
|
||
"worktree": str(task),
|
||
"task_id": "alpha",
|
||
"request_id": "mcp-1",
|
||
}}})
|
||
text = got["result"]["content"][0]["text"]
|
||
result_body = json.loads(text)
|
||
if result_body.get("run_status") == "succeeded":
|
||
break
|
||
if result_body.get("run_status") in ("failed", "cancelled", "unknown"):
|
||
self.fail(f"MCP run did not succeed: {result_body}")
|
||
if got["result"].get("isError") and result_body.get("code") not in (
|
||
"NOT_FOUND", "UNKNOWN_REQUEST"):
|
||
if result_body.get("code"):
|
||
self.fail(f"MCP supervisor rejected: {result_body}")
|
||
time.sleep(0.3)
|
||
self.assertIsNotNone(result_body)
|
||
self.assertEqual(result_body.get("run_status"), "succeeded", result_body)
|
||
rdir = self.report_dir("alpha", "mcp-1", task)
|
||
self.assertTrue((rdir / "receipt.json").is_file())
|
||
self.assertTrue((rdir / "report.md").is_file())
|
||
# nested request schema present for auto-dispatch
|
||
req_schema = next(t for t in tools["result"]["tools"] if t["name"] == "pi_subagent_run")
|
||
props = req_schema["inputSchema"]["properties"]["request"]["properties"]
|
||
for key in ("role_id", "request_id", "goal", "authorization_ref",
|
||
"source_sha", "input_files", "timeout_seconds"):
|
||
self.assertIn(key, props)
|
||
self.assertNotIn("config_path", props)
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
proc.wait(timeout=3)
|
||
|
||
def _start_mcp(self, mcp_path, cwd):
|
||
env = self.env({"AGENTCTL_REPO": str(self.root)})
|
||
env["PI_SUBAGENT_PI_BIN"] = str(self.fake_pi)
|
||
proc = subprocess.Popen(
|
||
["node", str(mcp_path)],
|
||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
text=True, cwd=str(cwd), env=env)
|
||
return proc
|
||
|
||
def _mcp_rpc(self, proc, msg, timeout=20.0):
|
||
if "id" not in msg and str(msg.get("method", "")).startswith("notifications/"):
|
||
proc.stdin.write(json.dumps(msg) + "\n")
|
||
proc.stdin.flush()
|
||
return None
|
||
proc.stdin.write(json.dumps(msg) + "\n")
|
||
proc.stdin.flush()
|
||
line = self._mcp_recv_line(proc, timeout=timeout)
|
||
self.assertTrue(line.strip(), f"empty MCP line; stderr={self._mcp_stderr_preview(proc)!r}")
|
||
return json.loads(line)
|
||
|
||
def test_mcp_from_worktree_uses_main_agentctl(self):
|
||
"""Server INSTALL_ROOT may be a linked worktree; agentctl must resolve from main."""
|
||
task = self.initialized_task()
|
||
mcp_wt = task / "scripts" / "mcp" / "pi-subagents-mcp.mjs"
|
||
self.assertTrue(mcp_wt.is_file(), "worktree must contain MCP script from fixture commit")
|
||
# Distinct paths: worktree install vs main tip
|
||
self.assertNotEqual(mcp_wt.resolve(), (self.root / "scripts/mcp/pi-subagents-mcp.mjs").resolve())
|
||
proc = self._start_mcp(mcp_wt, task)
|
||
try:
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||
"clientInfo": {"name": "test", "version": "0"}}})
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||
roles = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_roles",
|
||
"arguments": {"worktree": str(task),
|
||
"task_id": "alpha"}}})
|
||
self.assertFalse(roles["result"].get("isError"), roles)
|
||
run = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_run", "arguments": {
|
||
"worktree": str(task),
|
||
"task_id": "alpha",
|
||
"request": self.base_request(request_id="mcp-main-1"),
|
||
}}})
|
||
self.assertFalse(run["result"].get("isError"), run)
|
||
deadline = time.monotonic() + 25
|
||
while time.monotonic() < deadline:
|
||
got = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_result", "arguments": {
|
||
"worktree": str(task), "task_id": "alpha",
|
||
"request_id": "mcp-main-1",
|
||
}}})
|
||
body = json.loads(got["result"]["content"][0]["text"])
|
||
if body.get("run_status") == "succeeded":
|
||
break
|
||
if body.get("run_status") in ("failed", "cancelled", "unknown") or got["result"].get("isError"):
|
||
self.fail(body)
|
||
time.sleep(0.2)
|
||
else:
|
||
self.fail("timeout waiting for MCP run via worktree-installed server")
|
||
self.assertTrue((self.report_dir("alpha", "mcp-main-1", task) / "receipt.json").is_file())
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
def test_mcp_surfaces_role_disabled_and_scope_refusal(self):
|
||
task = self.initialized_task()
|
||
mcp = self.root / "scripts" / "mcp" / "pi-subagents-mcp.mjs"
|
||
proc = self._start_mcp(mcp, self.root)
|
||
try:
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||
"clientInfo": {"name": "t", "version": "0"}}})
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||
# Disabled auxiliary — typed failure, no receipt forever
|
||
run = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_run", "arguments": {
|
||
"worktree": str(task),
|
||
"task_id": "alpha",
|
||
"request": self.base_request(
|
||
request_id="mcp-aux", role_id="auxiliary"),
|
||
}}})
|
||
self.assertFalse(run["result"].get("isError"), "accept is async")
|
||
deadline = time.monotonic() + 15
|
||
saw = None
|
||
while time.monotonic() < deadline:
|
||
got = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_result", "arguments": {
|
||
"worktree": str(task), "task_id": "alpha",
|
||
"request_id": "mcp-aux",
|
||
}}})
|
||
saw = json.loads(got["result"]["content"][0]["text"])
|
||
if got["result"].get("isError") and saw.get("code") == "ROLE_DISABLED":
|
||
break
|
||
if saw.get("run_status") == "running":
|
||
self.assertFalse(got["result"].get("isError"), "running must not be isError")
|
||
time.sleep(0.2)
|
||
else:
|
||
self.fail(f"expected ROLE_DISABLED via MCP result, got {saw}")
|
||
self.assertEqual(saw.get("code"), "ROLE_DISABLED")
|
||
# No unleased source writes under worktree for rejected run
|
||
mcp_tmp = list((task / ".doc/test-reports/assets/pi-subagents").glob(".mcp-*"))
|
||
self.assertEqual(mcp_tmp, [])
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
# Scope-refused registered task (no report scope)
|
||
task2 = self.initialized_task(task="beta", scopes=("src",))
|
||
proc2 = self._start_mcp(mcp, self.root)
|
||
try:
|
||
self._mcp_rpc(proc2, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||
"clientInfo": {"name": "t", "version": "0"}}})
|
||
self._mcp_rpc(proc2, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||
req = self.base_request(request_id="mcp-scope", task_id="beta")
|
||
run = self._mcp_rpc(proc2, {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_run", "arguments": {
|
||
"worktree": str(task2),
|
||
"task_id": "beta",
|
||
"request": req,
|
||
}}})
|
||
self.assertFalse(run["result"].get("isError"))
|
||
deadline = time.monotonic() + 15
|
||
saw = None
|
||
while time.monotonic() < deadline:
|
||
got = self._mcp_rpc(proc2, {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_result", "arguments": {
|
||
"worktree": str(task2), "task_id": "beta",
|
||
"request_id": "mcp-scope",
|
||
}}})
|
||
saw = json.loads(got["result"]["content"][0]["text"])
|
||
if got["result"].get("isError") and saw.get("code"):
|
||
break
|
||
time.sleep(0.2)
|
||
else:
|
||
self.fail(f"expected scope/lease error via MCP, got {saw}")
|
||
self.assertTrue(got["result"].get("isError"))
|
||
self.assertEqual(saw.get("code"), "PATH_OUT_OF_SCOPE", saw)
|
||
finally:
|
||
proc2.terminate()
|
||
try:
|
||
proc2.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc2.kill()
|
||
|
||
def test_mcp_running_poll_is_not_error(self):
|
||
task = self.initialized_task()
|
||
mcp = self.root / "scripts" / "mcp" / "pi-subagents-mcp.mjs"
|
||
env = self.env({"AGENTCTL_REPO": str(self.root), "FAKE_PI_MODE": "hang",
|
||
"PI_SUBAGENT_PI_BIN": str(self.fake_pi)})
|
||
proc = subprocess.Popen(
|
||
["node", str(mcp)],
|
||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
text=True, cwd=str(self.root), env=env)
|
||
try:
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||
"clientInfo": {"name": "t", "version": "0"}}})
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||
self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_run", "arguments": {
|
||
"worktree": str(task),
|
||
"task_id": "alpha",
|
||
"request": self.base_request(
|
||
request_id="mcp-hang2", timeout_seconds=12),
|
||
}}})
|
||
deadline = time.monotonic() + 10
|
||
saw_running = False
|
||
while time.monotonic() < deadline:
|
||
got = self._mcp_rpc(proc, {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||
"params": {"name": "pi_subagent_result", "arguments": {
|
||
"worktree": str(task), "task_id": "alpha",
|
||
"request_id": "mcp-hang2",
|
||
}}})
|
||
body = json.loads(got["result"]["content"][0]["text"])
|
||
if body.get("run_status") == "running":
|
||
self.assertFalse(got["result"].get("isError"), body)
|
||
saw_running = True
|
||
break
|
||
if got["result"].get("isError") and body.get("code") in ("NOT_FOUND", "UNKNOWN_REQUEST"):
|
||
time.sleep(0.15)
|
||
continue
|
||
time.sleep(0.15)
|
||
self.assertTrue(saw_running, "expected a non-error running poll")
|
||
finally:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=8)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|