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(比控制台门控窄,服务端报错原文展示)。
This commit is contained in:
1 parent
154c18e25d
commit
ef532a38c2
7 files changed
+2128
-2
No files matched your search
@@ -0,0 +1,430 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user