Files
Agent-System/scripts/tests/test_agent_worktree.py
T
WeiCHEN 33c7b9a260 feat(agentctl): externalize governance policy to JSON
Load configs/agent-governance.json from the main worktree with fail-closed
POLICY_* codes, and keep test fixtures writing the same contract file.
2026-09-23 14:50:56 +08:00

339 lines
16 KiB
Python

"""Worktree lifecycle checks use real Git and the real agentctl registry."""
import fcntl
import importlib.util
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1]
class WorktreeTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) / "repo"
self.root.mkdir()
(self.root / "scripts").mkdir()
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py"):
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
(self.root / "configs").mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE.parent / "configs" / "agent-governance.json",
self.root / "configs" / "agent-governance.json")
(self.root / ".gitignore").write_text(".worktrees/\n.locks/\nignored-evidence/\n")
(self.root / "source.txt").write_text("base\n")
self.env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
for name in ("AGENTCTL_RUN_ID", "AGENTCTL_TASK_ID"):
self.env.pop(name, None)
self.git("init", "-b", "dev")
self.git("config", "user.email", "test@example.invalid")
self.git("config", "user.name", "Test")
self.git("add", ".")
self.git("commit", "-qm", "fixture")
self.base = self.git("rev-parse", "HEAD").stdout.strip()
def run_cmd(self, args, cwd=None, env=None):
return subprocess.run(args, cwd=cwd or self.root, env=env or self.env,
text=True, capture_output=True, timeout=20)
def git(self, *args, cwd=None):
result = self.run_cmd(["git", *args], cwd=cwd)
self.assertEqual(result.returncode, 0, result.stderr)
return result
def ctl(self, *args, cwd=None):
return self.run_cmd([sys.executable, str(self.root / "scripts/agentctl.py"), *args], cwd=cwd)
def init(self):
result = self.ctl("init")
self.assertEqual(result.returncode, 0, result.stderr)
def helper(self, *args, script_root=None, cwd=None, env=None):
script = (script_root or self.root) / "scripts/agent-worktree.sh"
return self.run_cmd(["bash", str(script), *args], cwd=cwd, env=env)
def create(self, branch="feature/task"):
result = self.helper("create", branch)
self.assertEqual(result.returncode, 0, result.stderr)
return Path(json.loads(result.stdout)["worktree"])
def remove_with_visibility_fixture(self, *args):
"""Fixture only process cwd enumeration; Git, registry and lock checks stay real.
This host may hide unrelated same-user daemon cwds. Positive removal tests
model a fully visible process namespace containing the helper itself.
Live cwd rejection below still runs the actual host process enumeration.
"""
code = '''
import importlib.util, os, sys
from pathlib import Path
from unittest import mock
spec = importlib.util.spec_from_file_location("helper", sys.argv[1])
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
def visible_cwds():
yield str(os.getpid()), Path.cwd()
sys.argv = sys.argv[1:]
with mock.patch.object(helper, "iter_same_user_cwds", visible_cwds):
sys.exit(helper.main())
'''
return self.run_cmd([sys.executable, "-c", code, str(self.root / "scripts/agent_worktree.py"), "remove", *args])
def test_list_is_read_only_without_init(self):
result = self.helper("list")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(self.root), result.stdout)
self.assertFalse((self.root / ".locks").exists())
self.assertFalse((self.root / ".worktrees").exists())
def test_write_requires_explicit_init(self):
result = self.helper("create", "feature/task")
self.assertNotEqual(result.returncode, 0)
self.assertIn("NOT_INITIALIZED", result.stderr)
self.assertFalse((self.root / ".worktrees").exists())
self.assertFalse((self.root / ".locks").exists())
def test_create_from_feature_uses_current_dev_and_canonical_main(self):
self.init()
first = self.create()
self.git("commit", "--allow-empty", "-qm", "feature-only", cwd=first)
result = self.helper("create", "fix/second", script_root=first, cwd=first / "scripts")
self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(result.stdout)
self.assertEqual(data["base_dev_sha"], self.base)
self.assertEqual(Path(data["worktree"]).resolve(), (self.root / ".worktrees/second").resolve())
self.assertEqual(self.git("rev-parse", "fix/second").stdout.strip(), self.base)
self.assertFalse((first / ".worktrees").exists())
self.assertFalse((first / ".locks").exists())
def test_create_with_main_on_unrelated_branch_uses_dev(self):
self.init()
self.git("checkout", "-b", "feature/legacy")
self.git("commit", "--allow-empty", "-qm", "legacy-only")
self.create()
self.assertEqual(self.git("rev-parse", "feature/task").stdout.strip(), self.base)
self.assertEqual(self.git("branch", "--show-current").stdout.strip(), "feature/legacy")
def test_rejects_traversal_nested_and_protected_branches(self):
self.init()
for branch in ("dev", "main", "test", "feature/../escape", "feature/a/b", "fix/.hidden", "feature/-x"):
with self.subTest(branch=branch):
self.assertNotEqual(self.helper("create", branch).returncode, 0)
self.assertFalse((self.root / ".worktrees").exists())
def test_same_task_id_and_existing_branch_do_not_collide(self):
self.init()
first = self.create()
self.assertNotEqual(self.helper("create", "fix/task").returncode, 0)
self.assertNotEqual(self.helper("create", "feature/task").returncode, 0)
self.assertEqual(self.git("branch", "--show-current", cwd=first).stdout.strip(), "feature/task")
def test_symlink_parent_or_target_is_refused(self):
self.init()
outside = Path(self.tmp.name) / "outside"
outside.mkdir()
parent = self.root / ".worktrees"
parent.symlink_to(outside, target_is_directory=True)
self.assertNotEqual(self.helper("create", "feature/task").returncode, 0)
self.assertEqual(list(outside.iterdir()), [])
parent.unlink()
parent.mkdir()
(parent / "task").symlink_to(outside, target_is_directory=True)
self.assertNotEqual(self.helper("create", "feature/task").returncode, 0)
self.assertEqual(list(outside.iterdir()), [])
def test_stable_lock_refuses_concurrent_writer(self):
self.init()
lock = self.root / ".locks/os/worktrees.lock"
with lock.open("a+") as holder:
inode = lock.stat().st_ino
fcntl.flock(holder.fileno(), fcntl.LOCK_EX)
result = self.helper("create", "feature/task")
self.assertNotEqual(result.returncode, 0)
self.assertIn("busy", result.stderr)
self.create()
self.assertEqual(lock.stat().st_ino, inode)
def test_forged_inherited_lease_is_refused(self):
self.init()
env = {**self.env, "AGENTCTL_RUN_ID": "forged", "AGENTCTL_TASK_ID": "task"}
self.assertNotEqual(self.helper("create", "feature/task", env=env).returncode, 0)
self.assertFalse((self.root / ".worktrees/task").exists())
def test_cleanup_legacy_mapping_and_nonforced_branch_delete_with_visibility_fixture(self):
self.init()
legacy = self.root / ".worktrees/feature-task"
self.git("worktree", "add", "-b", "feature/task", str(legacy), self.base)
result = self.remove_with_visibility_fixture("feature/task", "--delete-branch")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(legacy.exists())
self.assertNotIn("refs/heads/feature/task", self.git("show-ref").stdout)
def test_cleanup_refuses_unmerged_commit(self):
self.init()
task = self.create()
self.git("commit", "--allow-empty", "-qm", "unique", cwd=task)
result = self.helper("remove", "feature/task", "--delete-branch")
self.assertNotEqual(result.returncode, 0)
self.assertIn("ancestor", result.stderr)
self.assertTrue(task.exists())
def test_branch_delete_refusal_preserves_branch_without_force_with_visibility_fixture(self):
self.init()
self.git("branch", "feature/legacy", self.base)
self.git("commit", "--allow-empty", "-qm", "dev-next")
task = self.create()
self.git("checkout", "feature/legacy")
result = self.remove_with_visibility_fixture("feature/task", "--delete-branch")
self.assertNotEqual(result.returncode, 0)
self.assertIn("branch retained", result.stderr)
self.assertFalse(task.exists())
self.assertIn("refs/heads/feature/task", self.git("show-ref").stdout)
def test_cleanup_refuses_tracked_untracked_and_ignored_evidence(self):
self.init()
task = self.create()
for relative in ("source.txt", "new-evidence.txt", "ignored-evidence/only-copy.log"):
with self.subTest(relative=relative):
file = task / relative
original = file.read_text() if file.exists() else None
file.parent.mkdir(exist_ok=True)
file.write_text("unique evidence\n")
result = self.helper("remove", "feature/task", "--delete-branch")
self.assertNotEqual(result.returncode, 0)
self.assertTrue(file.exists())
self.assertEqual(file.read_text(), "unique evidence\n")
if original is not None:
file.write_text(original)
else:
file.unlink()
def test_cleanup_refuses_live_process_cwd(self):
self.init()
task = self.create()
process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=task)
try:
result = self.helper("remove", "feature/task")
self.assertNotEqual(result.returncode, 0)
self.assertIn(f"process {process.pid} still uses", result.stderr)
self.assertTrue(task.exists())
finally:
process.terminate()
process.wait(timeout=5)
def test_cleanup_refuses_unfinished_git_operation(self):
self.init()
task = self.create()
marker = Path(self.git("rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD", cwd=task).stdout.strip())
marker.write_text(self.base + "\n")
result = self.helper("remove", "feature/task")
self.assertNotEqual(result.returncode, 0)
self.assertIn("unfinished", result.stderr)
self.assertTrue(task.exists())
def test_cleanup_actual_branch_mapping_with_visibility_fixture(self):
self.init()
first = self.create()
other = self.root / ".worktrees/legacy-other"
self.git("worktree", "add", "-b", "fix/task", str(other), self.base)
result = self.remove_with_visibility_fixture("fix/task")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(first.exists())
self.assertFalse(other.exists())
def test_inherited_worktree_lease_does_not_double_lock(self):
self.init()
task = self.create()
result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts",
"--capability", "git:worktrees", cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
result = self.ctl("with", "--task", "task", "--resource", "git:worktrees", "--cwd", str(self.root),
"--", "bash", str(self.root / "scripts/agent-worktree.sh"), "create", "fix/child", cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.root / ".worktrees/child").exists())
def test_cleanup_lifecycle_lock_prevents_a_new_run(self):
self.init()
task = self.create()
result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts", cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
key = hashlib.sha256(str(task.resolve()).encode()).hexdigest()
lock = self.root / ".locks/os" / f"worktree-{key}.lock"
marker = Path(self.tmp.name) / "should-not-exist"
with lock.open("a+") as holder:
fcntl.flock(holder.fileno(), fcntl.LOCK_EX)
result = self.ctl("with", "--task", "task", "--resource", "path:scripts", "--wait", "0", "--",
sys.executable, "-c", "from pathlib import Path; import sys; Path(sys.argv[1]).touch()",
str(marker))
self.assertNotEqual(result.returncode, 0)
self.assertFalse(marker.exists())
def test_cleanup_refuses_active_registry_operation_even_with_other_cwd(self):
self.init()
task = self.create()
result = self.ctl("task", "register", "--id", "task", "--owner", "test", "--scope", "scripts", cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
ready = Path(self.tmp.name) / "ready"
child_code = "from pathlib import Path; import os,time,sys; os.chdir(sys.argv[2]); Path(sys.argv[1]).touch(); time.sleep(30)"
proc = subprocess.Popen([sys.executable, str(self.root / "scripts/agentctl.py"), "with", "--task", "task",
"--resource", "path:scripts", "--", sys.executable,
"-c", child_code, str(ready), str(self.root)], cwd=self.root, env=self.env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
deadline = time.monotonic() + 5
while not ready.exists() and proc.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue(ready.exists(), "supervised operation did not start")
result = self.helper("remove", "feature/task")
self.assertNotEqual(result.returncode, 0)
self.assertTrue(task.exists())
self.assertNotIn("still uses", result.stderr)
self.assertRegex(result.stderr, "active registered operation|live or unresolved operation")
finally:
proc.terminate()
proc.communicate(timeout=8)
class ProcessVisibilityTest(unittest.TestCase):
def test_unknown_same_uid_inaccessible_cwd_is_not_skipped(self):
spec = importlib.util.spec_from_file_location("agent_worktree_under_test", SOURCE / "agent_worktree.py")
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
def unreadable():
raise helper.Refused("cannot verify cwd of same-user process 987654321; cleanup_pending")
with tempfile.TemporaryDirectory() as temp:
with mock.patch.object(helper, "iter_same_user_cwds", side_effect=unreadable):
with self.assertRaisesRegex(helper.Refused, "cannot verify cwd"):
helper.assert_no_process_cwd(Path(temp) / "task")
def test_linux_permission_error_path_remains_fail_closed(self):
spec = importlib.util.spec_from_file_location("agent_worktree_under_test", SOURCE / "agent_worktree.py")
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
with tempfile.TemporaryDirectory() as temp:
proc = Path(temp) / "987654321"
proc.mkdir()
(proc / "comm").write_text("coding-agent\n")
with mock.patch.object(helper.sys, "platform", "linux"), \
mock.patch.object(helper.Path, "iterdir", return_value=iter([proc])), \
mock.patch.object(helper.os, "getuid", return_value=0), \
mock.patch.object(helper.os, "readlink", side_effect=PermissionError), \
mock.patch.object(Path, "stat", lambda self: type("S", (), {"st_uid": 0})()):
with self.assertRaisesRegex(helper.Refused, "cannot verify cwd"):
helper.assert_no_process_cwd(Path(temp) / "task")
if __name__ == "__main__":
unittest.main()