线程: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(比控制台门控窄,服务端报错原文展示)。
338 lines
13 KiB
Python
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()
|