线程: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(比控制台门控窄,服务端报错原文展示)。
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
|
||
|
||
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
|
||
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
|
||
端点负责(已有单测覆盖)。
|
||
"""
|
||
import http.client
|
||
import os
|
||
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: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.auth import AuthStore
|
||
from cloud.server import create_server
|
||
|
||
|
||
class ConsoleTestCase(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.server = create_server(self.db_path, "127.0.0.1", 0)
|
||
self.port = self.server.server_address[1]
|
||
self.thread = threading.Thread(
|
||
target=self.server.serve_forever, daemon=True)
|
||
self.thread.start()
|
||
|
||
def tearDown(self):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
self.thread.join(timeout=5)
|
||
try:
|
||
self.auth.conn.close()
|
||
except Exception:
|
||
pass
|
||
self.tmp.cleanup()
|
||
|
||
def _get(self, path, key=None):
|
||
conn = http.client.HTTPConnection(
|
||
"127.0.0.1", self.port, timeout=10)
|
||
headers = {}
|
||
if key is not None:
|
||
headers["X-API-Key"] = key
|
||
try:
|
||
conn.request("GET", path, headers=headers)
|
||
resp = conn.getresponse()
|
||
return resp.status, dict(resp.getheaders()), resp.read()
|
||
finally:
|
||
conn.close()
|
||
|
||
def test_console_serves_html_without_key(self):
|
||
status, headers, body = self._get("/console")
|
||
self.assertEqual(status, 200)
|
||
self.assertIn("text/html", headers.get("Content-Type", ""))
|
||
text = body.decode("utf-8")
|
||
self.assertIn("Codex 命令台", text)
|
||
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
||
for marker in ("/health", "/projects", "/tasks", "/workers",
|
||
"X-API-Key", "agentBox", "/agents",
|
||
"peerMessage", "/messages",
|
||
"threadBox", "threadGateNote", "threadList",
|
||
"THREAD_MUTATION_STATUSES",
|
||
"/thread", "/threads",
|
||
"/thread/rename", "/thread/archive",
|
||
"/thread/unarchive", "/thread/compact",
|
||
"skillList", "appList", "mcpList", "mcpOauthBox",
|
||
"/skills", "/apps", "/mcp-servers",
|
||
"/mcp-servers/reload", "/oauth-login"):
|
||
self.assertIn(marker, text)
|
||
|
||
def test_console_trailing_slash_same_page(self):
|
||
status, _headers, body = self._get("/console/")
|
||
self.assertEqual(status, 200)
|
||
self.assertIn("Codex 命令台", body.decode("utf-8"))
|
||
|
||
def test_console_subpath_not_routed(self):
|
||
# 未知子路径先过鉴权(无 key 时 401),有 key 时才落到 404;
|
||
# 绝不能把 /console/extra 也当成壳 serve 出去。
|
||
status, _headers, _body = self._get("/console/extra")
|
||
self.assertEqual(status, 401)
|
||
status, _headers, _body = self._get("/console/extra", self.owner_key)
|
||
self.assertEqual(status, 404)
|
||
|
||
def test_console_does_not_leak_data_without_key(self):
|
||
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
|
||
_status, _headers, body = self._get("/console")
|
||
text = body.decode("utf-8")
|
||
self.assertNotIn(self.owner_id, text)
|
||
|
||
def test_console_init_loads_relay_models(self):
|
||
# 回访用户 key 已在 localStorage 里:初始化序列必须含
|
||
# loadRelayModels 调用,否则模型下拉永远只有默认项
|
||
# (2026-10-27 实测:此前只在点“保存”时调一次)。
|
||
_status, _headers, body = self._get("/console")
|
||
text = body.decode("utf-8")
|
||
anchor = "setKeyState();\nloadHealth();\nloadProjects();\nloadWorkers();"
|
||
self.assertIn(anchor, text)
|
||
init = text[text.index(anchor):]
|
||
self.assertIn("if (getKey()) loadRelayModels();", init)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|