"""Thread ops server tests: GET/POST /tasks//thread* endpoints. No real host.py CLI, no real codex binary: a stub script written into tmp pretends to be host.py and prints canned JSON per the frozen CLI contract - one JSON object on stdout, nonzero exit plus {"error": ...} on failure. The stub reads a scenario JSON file and appends every invocation to a log file so tests can assert the argv the server passed, including the derived --cwd worktree path. """ import http.client import json import os import sqlite3 import sys import tempfile import threading import unittest from pathlib import Path try: from cloud.auth import AuthStore from cloud.server import create_server except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.server import create_server STUB = r'''import json import os import sys def main(): argv = sys.argv[1:] cmd = None rest = [] i = 0 while i < len(argv): a = argv[i] if a == "--codex-home": i += 2 continue if a.startswith("--codex-home="): i += 1 continue if cmd is None: cmd = a i += 1 continue rest.append(a) i += 1 log = os.environ.get("THREADOPS_STUB_LOG") if log: with open(log, "a", encoding="utf-8") as f: f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n") scenario = {} path = os.environ.get("THREADOPS_STUB_SCENARIO") if path and os.path.exists(path): with open(path, "r", encoding="utf-8") as f: scenario = json.load(f) if scenario.get("fail_cmd") == cmd: print(json.dumps({"error": scenario.get("fail_error", "boom")})) sys.exit(1) if scenario.get("raw_stdout_for") == cmd: print(scenario.get("raw_stdout", "")) return opts = {} j = 0 while j < len(rest): a = rest[j] if a.startswith("--") and j + 1 < len(rest) and not rest[j + 1].startswith("--"): opts[a] = rest[j + 1] j += 2 elif a.startswith("--"): opts[a] = True j += 1 else: j += 1 if cmd == "thread-list": print(json.dumps({"data": scenario.get("threads", [ {"id": "t-old", "updatedAt": 100, "createdAt": 90}, {"id": "t-new", "updatedAt": 200, "createdAt": 190}, ])})) elif cmd == "thread-read": thread = dict(scenario.get("thread", {"id": "t-new", "updatedAt": 200})) if opts.get("--include-turns"): thread["turns"] = [{"id": "turn-1"}] print(json.dumps({"thread": thread})) elif cmd == "thread-rename": print(json.dumps({"ok": True, "name": opts.get("--name")})) elif cmd in ("thread-archive", "thread-unarchive", "thread-compact"): print(json.dumps({"ok": True})) else: print(json.dumps({"error": "unknown subcommand"})) sys.exit(1) main() ''' class ThreadOpsTestCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.db_path = os.path.join(self.tmp.name, "control.db") self.auth = AuthStore(self.db_path) self.owner_id, self.owner_key = self.auth.create_user("owner") self.reader_id, self.reader_key = self.auth.create_user("reader") self.outsider_id, self.outsider_key = self.auth.create_user("outsider") self.stub_path = os.path.join(self.tmp.name, "stub_host.py") with open(self.stub_path, "w", encoding="utf-8") as f: f.write(STUB) self.scenario_path = os.path.join(self.tmp.name, "scenario.json") self.log_path = os.path.join(self.tmp.name, "stub.log") self._write_scenario({}) self._old_host_py = os.environ.get("CODEX_HOST_PY") os.environ["CODEX_HOST_PY"] = self.stub_path os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path os.environ["THREADOPS_STUB_LOG"] = self.log_path self.server = create_server(self.db_path, "127.0.0.1", 0) self.server.tenants_root = os.path.join(self.tmp.name, "tenants") self.server.host_worktrees_dir = os.path.join( self.tmp.name, "worktrees") self.port = self.server.server_address[1] self.thread = threading.Thread( target=self.server.serve_forever, daemon=True) self.thread.start() status, self.project = self._req( "POST", "/projects", {"name": "demo"}, self.owner_key) self.assertEqual(status, 201, self.project) self.auth.set_member(self.project["id"], self.owner_id, self.reader_id, "viewer") def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) try: self.auth.conn.close() except Exception: pass if self._old_host_py is None: os.environ.pop("CODEX_HOST_PY", None) else: os.environ["CODEX_HOST_PY"] = self._old_host_py os.environ.pop("THREADOPS_STUB_SCENARIO", None) os.environ.pop("THREADOPS_STUB_LOG", None) self.tmp.cleanup() def _write_scenario(self, scenario): with open(self.scenario_path, "w", encoding="utf-8") as f: json.dump(scenario, f) def _stub_calls(self, cmd=None): if not os.path.exists(self.log_path): return [] calls = [] with open(self.log_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: calls.append(json.loads(line)) if cmd is not None: calls = [c for c in calls if c["cmd"] == cmd] return calls def _req(self, method, path, body=None, key=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) hdrs = {} payload = None if body is not None: payload = json.dumps(body).encode("utf-8") hdrs["Content-Type"] = "application/json" if key is not None: hdrs["X-API-Key"] = key try: conn.request(method, path, body=payload, headers=hdrs) resp = conn.getresponse() data = resp.read() finally: conn.close() try: parsed = json.loads(data.decode("utf-8")) if data else None except ValueError: parsed = None return resp.status, parsed def _create_task(self, **extra): body = {"project_id": self.project["id"], "prompt": "do work"} body.update(extra) status, task = self._req("POST", "/tasks", body, self.owner_key) self.assertEqual(status, 201, task) return task def _set_status(self, task_id, status): conn = sqlite3.connect(self.db_path) try: conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, task_id)) conn.commit() finally: conn.close() def _audit_actions(self, project_id): conn = sqlite3.connect(self.db_path) try: rows = conn.execute( "SELECT action FROM audit_log WHERE project_id = ?", (project_id,)).fetchall() finally: conn.close() return [r[0] for r in rows] def test_unauthorized_without_key(self): task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, None) self.assertEqual(status, 401, body) def test_forbidden_outsider_read(self): task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.outsider_key) self.assertEqual(status, 403, body) def test_forbidden_reader_cannot_mutate(self): task = self._create_task(executor="local") self._set_status(task["id"], "completed") status, body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.reader_key) self.assertEqual(status, 403, body) def test_forbidden_outsider_cannot_mutate(self): # P2 接线新增:非成员改操作同样 403,且不留审计。 task = self._create_task(executor="local") self._set_status(task["id"], "completed") status, body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.outsider_key) self.assertEqual(status, 403, body) self.assertNotIn("thread_archived", self._audit_actions(self.project["id"])) def test_task_not_found(self): status, body = self._req( "GET", "/tasks/nope/thread", None, self.owner_key) self.assertEqual(status, 404, body) status, body = self._req( "POST", "/tasks/nope/thread/archive", {}, self.owner_key) self.assertEqual(status, 404, body) def test_docker_task_409(self): task = self._create_task(executor="docker") self._set_status(task["id"], "completed") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 409, body) status, body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 409, body) self.assertEqual(self._stub_calls(), []) def test_terminal_gate_409_and_read_allowed(self): task = self._create_task(executor="local") self._set_status(task["id"], "running") status, body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 409, body) self.assertEqual(body["status"], "running") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) def test_merged_task_mutation_409_but_read_ok(self): task = self._create_task(executor="local") self._set_status(task["id"], "merged") status, _body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 409) status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) def test_threads_list_passthrough_and_cwd(self): task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual( [t["id"] for t in body["threads"]], ["t-old", "t-new"]) expected_cwd = os.path.join( self.tmp.name, "worktrees", task["id"]) self.assertEqual(body["cwd"], expected_cwd) calls = self._stub_calls("thread-list") self.assertEqual(len(calls), 1) self.assertIn("--cwd", calls[0]["argv"]) idx = calls[0]["argv"].index("--cwd") self.assertEqual(calls[0]["argv"][idx + 1], expected_cwd) def test_thread_latest_non_archived_rule(self): self._write_scenario({"threads": [ {"id": "t-old", "updatedAt": 100, "createdAt": 90}, {"id": "t-arch", "updatedAt": 300, "createdAt": 290, "archived": True}, {"id": "t-new", "updatedAt": 200, "createdAt": 190}, ]}) task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["thread_id"], "t-new") def test_thread_tie_break_later_in_list_wins(self): self._write_scenario({"threads": [ {"id": "t-a", "updatedAt": 50, "createdAt": 50}, {"id": "t-b", "updatedAt": 50, "createdAt": 50}, ]}) task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["thread_id"], "t-b") def test_thread_not_found_when_empty(self): self._write_scenario({"threads": []}) task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 404, body) status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["threads"], []) def test_thread_read_include_turns(self): task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/thread" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertNotIn("turns", body["thread"]) status, body = self._req( "GET", "/tasks/%s/thread?include_turns=1" % task["id"], None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["thread"]["turns"], [{"id": "turn-1"}]) calls = self._stub_calls("thread-read") self.assertIn("--include-turns", calls[1]["argv"]) self.assertNotIn("--include-turns", calls[0]["argv"]) def test_rename_ok_and_audit(self): task = self._create_task(executor="local") self._set_status(task["id"], "completed") status, body = self._req( "POST", "/tasks/%s/thread/rename" % task["id"], {"name": "new name"}, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["result"]["name"], "new name") self.assertIn("thread_renamed", self._audit_actions(self.project["id"])) calls = self._stub_calls("thread-rename") self.assertEqual(len(calls), 1) self.assertIn("new name", calls[0]["argv"]) def test_rename_validation(self): task = self._create_task(executor="local") self._set_status(task["id"], "completed") status, _body = self._req( "POST", "/tasks/%s/thread/rename" % task["id"], {}, self.owner_key) self.assertEqual(status, 400) status, _body = self._req( "POST", "/tasks/%s/thread/rename" % task["id"], {"name": " "}, self.owner_key) self.assertEqual(status, 400) def test_archive_unarchive_compact_ok_and_audit(self): task = self._create_task(executor="local") self._set_status(task["id"], "failed") for action in ("archive", "unarchive", "compact"): status, body = self._req( "POST", "/tasks/%s/thread/%s" % (task["id"], action), {}, self.owner_key) self.assertEqual(status, 200, body) actions = self._audit_actions(self.project["id"]) self.assertIn("thread_archived", actions) self.assertIn("thread_unarchived", actions) self.assertIn("thread_compacted", actions) def test_cli_failure_passthrough_502(self): self._write_scenario( {"fail_cmd": "thread-list", "fail_error": "list boom"}) task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 502, body) self.assertEqual(body["error"], "list boom") self._write_scenario( {"fail_cmd": "thread-archive", "fail_error": "arch boom"}) self._set_status(task["id"], "completed") status, body = self._req( "POST", "/tasks/%s/thread/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 502, body) self.assertEqual(body["error"], "arch boom") def test_cli_bad_json_502(self): self._write_scenario( {"raw_stdout_for": "thread-list", "raw_stdout": "not json{"}) task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 502, body) def test_host_py_missing_502(self): os.environ["CODEX_HOST_PY"] = os.path.join( self.tmp.name, "does-not-exist.py") task = self._create_task(executor="local") status, body = self._req( "GET", "/tasks/%s/threads" % task["id"], None, self.owner_key) self.assertEqual(status, 502, body) self.assertIn("host.py", body["error"]) if __name__ == "__main__": unittest.main()