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

338 lines
13 KiB
Python

"""Skills/apps/MCP server tests: /projects/<id>/skills, /apps,
/mcp-servers, /mcp-servers/reload, /mcp-servers/<name>/oauth-login.
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 tenant work dir used as skills/list cwd.
"""
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 cmd == "skills-list":
print(json.dumps({"data": scenario.get("skills", [
{"cwd": "/work", "skills": [{"name": "demo-skill"}]},
])}))
elif cmd == "apps-list":
print(json.dumps({"data": [{"id": "app-1", "name": "Demo App"}],
"nextCursor": "cur-1"}))
elif cmd == "mcp-servers-list":
print(json.dumps({"data": [{"name": "srv-1",
"runtimeStatus": "running"}]}))
elif cmd == "mcp-servers-reload":
print(json.dumps({}))
elif cmd == "mcp-server-oauth-login":
if scenario.get("oauth_no_url"):
print(json.dumps({}))
else:
print(json.dumps({
"authorizationUrl": "https://example.invalid/oauth/authorize?x=1",
"loginId": "login-123"}))
else:
print(json.dumps({"error": "unknown subcommand"}))
sys.exit(1)
main()
'''
class SkillsMcpTestCase(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.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 _audit_actions(self, project_id):
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT action, detail FROM audit_log WHERE project_id = ?",
(project_id,)).fetchall()
finally:
conn.close()
return [(r[0], r[1]) for r in rows]
def _pid(self):
return self.project["id"]
def test_unauthorized_without_key(self):
status, body = self._req(
"GET", "/projects/%s/skills" % self._pid(), None, None)
self.assertEqual(status, 401, body)
def test_forbidden_outsider_reads(self):
for suffix in ("skills", "apps", "mcp-servers"):
status, body = self._req(
"GET", "/projects/%s/%s" % (self._pid(), suffix),
None, self.outsider_key)
self.assertEqual(status, 403, body)
def test_project_not_found(self):
status, body = self._req(
"GET", "/projects/nope/skills", None, self.owner_key)
self.assertEqual(status, 404, body)
def test_skills_list_passthrough_and_cwd(self):
status, body = self._req(
"GET", "/projects/%s/skills" % self._pid(), None,
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["project_id"], self._pid())
self.assertEqual(body["skills"], [
{"cwd": "/work", "skills": [{"name": "demo-skill"}]}])
calls = self._stub_calls("skills-list")
self.assertEqual(len(calls), 1)
self.assertIn("--cwd", calls[0]["argv"])
idx = calls[0]["argv"].index("--cwd")
expected = os.path.join(
self.tmp.name, "tenants", self._pid(), "work")
self.assertEqual(calls[0]["argv"][idx + 1], expected)
def test_reader_can_read_skills(self):
status, body = self._req(
"GET", "/projects/%s/skills" % self._pid(), None,
self.reader_key)
self.assertEqual(status, 200, body)
def test_apps_list_passthrough(self):
status, body = self._req(
"GET", "/projects/%s/apps" % self._pid(), None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["apps"], [{"id": "app-1", "name": "Demo App"}])
self.assertEqual(body["nextCursor"], "cur-1")
def test_mcp_servers_list_passthrough(self):
status, body = self._req(
"GET", "/projects/%s/mcp-servers" % self._pid(), None,
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["servers"], [
{"name": "srv-1", "runtimeStatus": "running"}])
def test_reload_ok_and_audit(self):
status, body = self._req(
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
{}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertTrue(body["reloaded"])
self.assertEqual(body["result"], {})
actions = [a for a, _d in self._audit_actions(self._pid())]
self.assertIn("mcp_servers_reloaded", actions)
def test_reload_forbidden_for_reader(self):
status, body = self._req(
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
{}, self.reader_key)
self.assertEqual(status, 403, body)
def test_reload_cli_failure_502_not_fake_success(self):
self._write_scenario(
{"fail_cmd": "mcp-servers-reload",
"fail_error": "reload rejected"})
status, body = self._req(
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
{}, self.owner_key)
self.assertEqual(status, 502, body)
self.assertEqual(body["error"], "reload rejected")
self.assertNotIn("reloaded", body)
def test_oauth_login_passthrough(self):
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{"scopes": ["read", "write"]}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(
body["authorizationUrl"],
"https://example.invalid/oauth/authorize?x=1")
self.assertEqual(body["loginId"], "login-123")
self.assertEqual(body["project_id"], self._pid())
self.assertEqual(body["server_name"], "srv-1")
calls = self._stub_calls("mcp-server-oauth-login")
self.assertEqual(len(calls), 1)
self.assertIn("--server-name", calls[0]["argv"])
idx = calls[0]["argv"].index("--server-name")
self.assertEqual(calls[0]["argv"][idx + 1], "srv-1")
self.assertIn("--scopes", calls[0]["argv"])
idx = calls[0]["argv"].index("--scopes")
self.assertEqual(calls[0]["argv"][idx + 1], "read,write")
actions = self._audit_actions(self._pid())
self.assertTrue(any(
a == "mcp_server_oauth_login_started" and "srv-1" in d
for a, d in actions))
def test_oauth_login_forbidden_for_reader(self):
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{}, self.reader_key)
self.assertEqual(status, 403, body)
def test_oauth_login_validation(self):
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{"scopes": "not-a-list"}, self.owner_key)
self.assertEqual(status, 400, body)
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{"timeoutSecs": 0}, self.owner_key)
self.assertEqual(status, 400, body)
def test_oauth_login_cli_failure_502(self):
self._write_scenario(
{"fail_cmd": "mcp-server-oauth-login",
"fail_error": "oauth rejected"})
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{}, self.owner_key)
self.assertEqual(status, 502, body)
self.assertEqual(body["error"], "oauth rejected")
def test_oauth_login_missing_url_502(self):
self._write_scenario({"oauth_no_url": True})
status, body = self._req(
"POST",
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
{}, self.owner_key)
self.assertEqual(status, 502, body)
self.assertNotIn("authorizationUrl", body)
if __name__ == "__main__":
unittest.main()