From e40c2c33bbd164907f4b27253c0883c3120851d5 Mon Sep 17 00:00:00 2001 From: openqj Date: Tue, 27 Oct 2026 01:02:24 +0800 Subject: [PATCH] feat: web command console v1 (GET /console) (#57) Co-authored-by: host --- cloud/README.md | 12 ++ cloud/console.html | 325 ++++++++++++++++++++++++++++++++++++ cloud/server.py | 13 ++ cloud/tests/test_console.py | 91 ++++++++++ 4 files changed, 441 insertions(+) create mode 100644 cloud/console.html create mode 100644 cloud/tests/test_console.py diff --git a/cloud/README.md b/cloud/README.md index c439003..c54a954 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -455,6 +455,18 @@ owner_user_id)` 只写 `project_members` 的 owner 成员记录,不建项目 记录本身;项目记录(name 等)由 server 写 `projects` 表。两者配合 才算一个完整项目,不要只调一处。 +## 命令台(v1) + +`GET /console` 出一个纯静态单页(`cloud/console.html`,无构建步骤), +与控制面同源挂载。页面本身不走鉴权(只是个壳,没有 key 时调不到 +任何数据);数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只 +存在本浏览器 localStorage。v1 功能:项目列表/新建、任务列表(10 秒 +自动刷新)/新建(含 goal 模式)/详情(result/error、成本)/重试/ +取消/合并/提 PR、workers、健康状态。访问方式:v1 不走公网 nginx, +经 SSH 隧道访问主机 127.0.0.1:8765/console(或 8766);公网入口 +(nginx 加 location)是后续决策——页面壳公开后数据仍凭 key, +但是否公开仍须单独定。 + ## 测试 ```bash diff --git a/cloud/console.html b/cloud/console.html new file mode 100644 index 0000000..0a28640 --- /dev/null +++ b/cloud/console.html @@ -0,0 +1,325 @@ + + + + + +Codex 命令台 + + + + +

Codex 命令台

+

控制面操作台 v1:任务是核心操作对象;成本、workers、健康状态附带查看。公网入口未开时经 SSH 隧道访问本机 8765/8766 端口。

+ +

API Key

+
+ + + + +
+ +

健康状态

+
+ 未加载 + +
+ +
+
+

项目

+
+ + +
+
+ + + +
+ +
+
+

Workers

+
+
未加载
+
+
+ +

新建任务

+
+ + +
+ + +
+ + +
+ +

任务列表 (每 10 秒自动刷新当前项目)

+
+ + +
+ + + +
任务prompt状态executor主机PR更新
先选项目
+ +

任务详情

+
点任务行查看详情
+ + +
+ + + + diff --git a/cloud/server.py b/cloud/server.py index c8a90d0..2357fa8 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -685,6 +685,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): "commit": _deployed_commit(), }) return + if path == "/console" and method == "GET": + # 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款 + # pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在 + # 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器 + # localStorage。页面文件与 server.py 同目录,改动随部署走。 + try: + here = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(here, "console.html"), + "r", encoding="utf-8") as f: + self._send_html(200, f.read()) + except OSError: + self._send_json(404, {"error": "console not found"}) + return if path.startswith("/oauth/"): # OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key, # 这正是 OAuth 要解决的问题),靠 state + client_secret diff --git a/cloud/tests/test_console.py b/cloud/tests/test_console.py new file mode 100644 index 0000000..fa9fb2b --- /dev/null +++ b/cloud/tests/test_console.py @@ -0,0 +1,91 @@ +"""命令台 /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"): + 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) + + +if __name__ == "__main__": + unittest.main()