Files
Agent-System/scripts/tests/test_agentctl_serve.py
T
weichen f9f055bee1 feat(agentctl): add governance console P1 serve and action APIs
Implement loopback HTTP console with claim new, integrate prepare, and pi packet sharing the same helpers as CLI; ship SPA and MCP brief/claim_archive wrappers.
2026-09-24 14:13:27 +00:00

367 lines
16 KiB
Python

#!/usr/bin/env python3
"""Black-box tests for agentctl serve / claim new / integrate prepare / pi packet."""
from __future__ import annotations
import json
import pathlib
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
SOURCE = pathlib.Path(__file__).resolve().parents[1] / "agentctl.py"
POLICY_SOURCE = SOURCE.parents[1] / "configs" / "agent-governance.json"
CONSOLE_STATIC = SOURCE.parent / "governance-console"
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
class ConsoleServeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="agentctl-serve-")
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", "Agentctl Test")
self.git("config", "user.email", "agentctl-test@example.invalid")
(self.root / "scripts").mkdir()
for name in (
"agentctl.py", "agentctl_w4.py", "agentctl_catalog.py",
"agentctl_visibility.py", "agentctl_console.py", "pi_subagents.py",
"agent_worktree.py",
):
src = SOURCE.parent / name
if src.exists():
shutil.copy2(src, self.root / "scripts" / name)
static_dest = self.root / "scripts" / "governance-console"
static_dest.mkdir(parents=True, exist_ok=True)
if (CONSOLE_STATIC / "index.html").exists():
shutil.copy2(CONSOLE_STATIC / "index.html", static_dest / "index.html")
else:
(static_dest / "index.html").write_text("<html>ok</html>", encoding="utf-8")
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/a", ".doc/ops", ".doc/status", ".doc/test-reports"):
(self.root / directory).mkdir(parents=True, exist_ok=True)
(self.root / "src/a" / ".keep").write_text("keep\n", encoding="utf-8")
self._write_current([])
(self.root / ".doc/status/FEATURE-MATRIX.md").write_text(
"# Features\n\n| record_id | 模块 | 状态 |\n| --- | --- | --- |\n| core | core | planned |\n",
encoding="utf-8")
self.git("add", ".")
self.git("commit", "-m", "fixture")
self.cli("init")
self._server = None
self._thread = None
def tearDown(self):
if self._server is not None:
self._server.shutdown()
self._server.server_close()
if self._thread is not None:
self._thread.join(timeout=5)
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 cli(self, *args, cwd=None, check=True):
result = subprocess.run(
[sys.executable, "-B", str(self.root / "scripts/agentctl.py"), *args],
cwd=cwd or self.root, text=True, capture_output=True, timeout=60,
)
if check:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result
def _last_json(self, text: str):
lines = [ln for ln in text.splitlines() if ln.strip()]
return json.loads(lines[-1])
def _write_current(self, claim_rows):
rows = [
"| owner | task | branch | worktree | mutex | status |",
"| --- | --- | --- | --- | --- | --- |",
*claim_rows,
]
body = (
"# Coordination\n\n## 焦点\n\nfocus\n\n## 并行认领\n\n"
+ "\n".join(rows) + "\n\n"
"## 清理接管与遗留现场\n\n"
"| 路径/事项 | 分类与保留原因 | cleanup_owner | 复查触发 |\n"
"| --- | --- | --- | --- |\n"
"| none | none | - | - |\n\n"
"## 集成槽位\n\n"
"| holder | phase | since | scope | queue |\n"
"|--------|-------|-------|-------|-------|\n"
"| - | idle | 2026-09-24 | test | - |\n\n"
"## 受控约束\n\n- c\n\n## 活跃入口\n\n- e\n"
)
(self.root / ".doc/ops/CURRENT.md").write_text(body, encoding="utf-8")
def initialized_task(self, task="alpha", capabilities=("docs:publish",)):
path = self.root / ".worktrees" / task
if not path.exists():
self.git("worktree", "add", "-b", "feature/" + task, str(path), "dev")
args = ["task", "register", "--id", task, "--owner", "Test Agent",
"--scope", "src/a", "--scope", ".doc"]
for cap in capabilities:
args.extend(("--capability", cap))
self.cli(*args, cwd=path)
return path
def start_server(self, host="127.0.0.1", port=None):
port = port or _free_port()
sys.path.insert(0, str(self.root / "scripts"))
import agentctl as ac # noqa: E402
import agentctl_console as console # noqa: E402
# Ensure policy loaded for this fixture repo.
repo = ac.Repo(str(self.root))
ac.require_policy(repo)
args = type("A", (), {"host": host, "port": port, "static": "scripts/governance-console"})()
# Build server without serve_forever so tests control lifecycle.
if host not in ("127.0.0.1", "localhost", "::1"):
with self.assertRaises(ac.Denied) as ctx:
console.serve(repo, args)
return None, ctx.exception
from http.server import ThreadingHTTPServer
# Re-enter serve setup by calling internal pattern: use CLI subprocess for smoke,
# and for unit endpoints use Handler via temporary monkey of serve_forever.
holder = {}
def run():
class Stoppable(ThreadingHTTPServer):
allow_reuse_address = True
# Reconstruct Handler by invoking console.serve until emit, then hijack.
# Simpler: spawn `agentctl serve` subprocess.
pass
proc = subprocess.Popen(
[sys.executable, "-B", str(self.root / "scripts/agentctl.py"),
"serve", "--host", host, "--port", str(port)],
cwd=str(self.root), text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
def _stop_proc(p=proc):
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=3)
except subprocess.TimeoutExpired:
p.kill()
if p.stdout:
p.stdout.close()
if p.stderr:
p.stderr.close()
self.addCleanup(_stop_proc)
# Wait until port accepts connections.
deadline = time.time() + 8
while time.time() < deadline:
if proc.poll() is not None:
out = (proc.stdout.read() if proc.stdout else "") + (proc.stderr.read() if proc.stderr else "")
raise AssertionError(f"serve exited early: {out}")
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
break
except OSError:
time.sleep(0.05)
else:
proc.kill()
raise AssertionError("serve did not bind in time")
return port, proc
def http_json(self, port, path, method="GET", body=None, expect_status=200):
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
data=data, method=method,
headers={"Content-Type": "application/json"} if data is not None else {},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode("utf-8")
status = resp.status
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8")
status = exc.code
ctype = exc.headers.get("Content-Type", "")
self.assertEqual(status, expect_status, raw)
if "json" in ctype or raw[:1] in "{[":
return json.loads(raw)
return raw
def test_non_loopback_host_denied(self):
sys.path.insert(0, str(self.root / "scripts"))
import agentctl as ac # noqa: E402
import agentctl_console as console # noqa: E402
repo = ac.Repo(str(self.root))
ac.require_policy(repo)
args = type("A", (), {"host": "0.0.0.0", "port": 7432, "static": "scripts/governance-console"})()
with self.assertRaises(ac.Denied) as ctx:
console.serve(repo, args)
self.assertEqual(ctx.exception.code, "INVALID_REQUEST")
def test_get_eight_endpoints_and_spa(self):
port, _ = self.start_server()
for path in (
"/api/status", "/api/brief", "/api/queue", "/api/claims",
"/api/cleanup", "/api/docs/drift", "/api/catalog/drift", "/api/daemon",
):
payload = self.http_json(port, path)
self.assertIsInstance(payload, (dict, str))
if path == "/api/brief":
self.assertTrue(isinstance(payload, str) and payload.startswith("#"))
else:
self.assertIsInstance(payload, dict)
brief_json = self.http_json(port, "/api/brief?format=json")
self.assertIn("brief", brief_json)
html = self.http_json(port, "/", expect_status=200)
self.assertIn("agentctl console", html if isinstance(html, str) else str(html))
def test_unknown_post_shell_404(self):
port, _ = self.start_server()
payload = self.http_json(port, "/api/shell", method="POST", body={}, expect_status=404)
self.assertEqual(payload["code"], "NOT_FOUND")
def test_claim_new_missing_task_400(self):
port, _ = self.start_server()
payload = self.http_json(port, "/api/claim/new", method="POST", body={}, expect_status=400)
self.assertEqual(payload["code"], "INVALID_REQUEST")
def test_claim_new_without_docs_publish_403(self):
self.initialized_task(capabilities=())
port, _ = self.start_server()
payload = self.http_json(
port, "/api/claim/new", method="POST",
body={"task": "alpha", "owner": "Test Agent", "mutex": "m1"},
expect_status=403,
)
self.assertEqual(payload["code"], "CAPABILITY_REQUIRED")
def test_claim_new_cli_create_replace_and_replay(self):
task = self.initialized_task(capabilities=("docs:publish",))
rid = "alpha-claim-new-test1"
created = self._last_json(self.cli(
"claim", "new", "--task", "alpha", "--owner", "Test Agent",
"--mutex", "m1", "--request-id", rid, cwd=task,
).stdout)
self.assertEqual(created["status"], "created")
text = (task / ".doc/ops/CURRENT.md").read_text(encoding="utf-8")
self.assertIn("| Test Agent | alpha |", text)
self.assertIn("| m1 | in_progress |", text)
# Identical row → idempotent (no second apply)
again = self._last_json(self.cli(
"claim", "new", "--task", "alpha", "--owner", "Test Agent",
"--mutex", "m1", "--request-id", rid, cwd=task,
).stdout)
self.assertEqual(again["status"], "idempotent")
# Without replace while row differs → refuse
denied = self.cli(
"claim", "new", "--task", "alpha", "--owner", "Test Agent",
"--mutex", "m2", "--request-id", "alpha-claim-new-test2", cwd=task, check=False,
)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("INVALID_REQUEST", denied.stderr + denied.stdout)
replaced = self._last_json(self.cli(
"claim", "new", "--task", "alpha", "--owner", "Test Agent",
"--mutex", "m2", "--replace", "--request-id", "alpha-claim-new-test3", cwd=task,
).stdout)
self.assertEqual(replaced["status"], "replaced")
text2 = (task / ".doc/ops/CURRENT.md").read_text(encoding="utf-8")
self.assertIn("| m2 | in_progress |", text2)
def test_integrate_prepare_first_parent_and_dirty_refuse(self):
feat = self.initialized_task(task="feat1", capabilities=("docs:publish",))
(feat / "src/a").mkdir(parents=True, exist_ok=True)
(feat / "src/a/note.txt").write_text("hello\n", encoding="utf-8")
self.git("add", "src/a/note.txt", cwd=feat)
self.git("commit", "-m", "feat change", cwd=feat)
expected = self.git("rev-parse", "refs/heads/dev")
# Prepare from main cwd is allowed by design.
out = self._last_json(self.cli(
"integrate", "prepare", "--task", "feat1", "--expected-dev", expected,
"--integrate-id", "integrate-feat1",
).stdout)
self.assertEqual(out["status"], "prepared")
self.assertEqual(out["first_parent"], expected)
integ = self.root / ".worktrees" / "integrate-feat1"
self.assertTrue(integ.is_dir())
# Idempotent on same base
again = self._last_json(self.cli(
"integrate", "prepare", "--task", "feat1", "--expected-dev", expected,
"--integrate-id", "integrate-feat1",
).stdout)
self.assertTrue(again.get("idempotent"))
# Dirty refuse on different attempt id that reuses? Use existing dirty tree.
(integ / "dirt.txt").write_text("x", encoding="utf-8")
# Advance expected to force non-idempotent path that hits dirty check —
# tip still same so idempotent returns early. Force by corrupting first-parent check:
# Create second integrate with dirty pre-created empty? Create dirty empty dir as worktree path.
dirty_id = "integrate-dirty"
dirty_path = self.root / ".worktrees" / dirty_id
# Manually add a fake dirty worktree at expected tip then make dirty.
self.git("worktree", "add", "-b", f"feature/{dirty_id}", str(dirty_path), expected)
(dirty_path / "dirt2.txt").write_text("y", encoding="utf-8")
denied = self.cli(
"integrate", "prepare", "--task", "feat1", "--expected-dev", expected,
"--integrate-id", dirty_id, check=False,
)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("INVALID_REQUEST", denied.stderr + denied.stdout)
def test_pi_packet_rejects_symbolic_and_writes(self):
task = self.initialized_task(capabilities=("docs:publish",))
# Ensure HEAD tracked file for freeze if needed
denied = self.cli(
"pi", "packet", "--task", "alpha", "--role", "research",
"--goal", "g", "--authorization-ref", "user:test",
"--source-sha", "HEAD", cwd=task, check=False,
)
self.assertNotEqual(denied.returncode, 0)
blob = denied.stderr + denied.stdout
self.assertTrue(
"INVALID_SOURCE_SHA" in blob or "INVALID_REQUEST" in blob or "denied" in blob.lower(),
blob,
)
head = self.git("rev-parse", "HEAD", cwd=task)
ok = self._last_json(self.cli(
"pi", "packet", "--task", "alpha", "--role", "research",
"--goal", "research goal", "--authorization-ref", "user:test",
"--source-sha", head, "--request-id", "pkt-1",
"--approved-command", "python3 -c print(1)",
"--write", cwd=task,
).stdout)
self.assertEqual(ok["status"], "ok")
self.assertEqual(ok["packet"]["source_sha"], head)
self.assertEqual(ok["packet"]["role_id"], "research")
rel = ok["path"]
self.assertTrue(rel.endswith("packet.json"))
written = json.loads((task / rel).read_text(encoding="utf-8"))
self.assertEqual(written["request_id"], "pkt-1")
self.assertEqual(written["approved_commands"], [["python3", "-c", "print(1)"]])
if __name__ == "__main__":
unittest.main()