Files
host ef532a38c2 feat: 线程操作与 skills/MCP 管理面暴露(host CLI + 服务端 API + 控制台)
线程:host.py 新增 thread-list/read/rename/archive/unarchive/compact 子命令(纯 JSON stdout 契约,read/rename/unarchive/compact 先 resume);服务端按任务定 scope(thread/list 的 cwd 过滤解析,Docker/cloud/异机 409,改操作仅终态,审计 thread_*);控制台线程区(元数据、列表、重命名、归档、取消归档、压缩,终态门控)。

skills/MCP:host.py 新增 skills-list/apps-list/mcp-servers-list/mcp-servers-reload/mcp-server-oauth-login 子命令;服务端按项目 scope(读 read,改 manage_project,审计 mcp_*);控制台技能与 MCP 区(列表、重载、OAuth 发起展示,绝不谎称登录完成)。

测试:host 84、cloud 687(4 跳过)、deploy 19 全过。诚实边界:Docker 任务线程在容器内故 409;无仓库任务推不出 cwd 时 404;OAuth 只做到发起透传,完成态靠协议通知;merged/merge-failed 改操作 409(比控制台门控窄,服务端报错原文展示)。
2026-10-27 16:37:28 +00:00

431 lines
17 KiB
Python

"""Thread ops server tests: GET/POST /tasks/<id>/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()