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
+
+
+
+
+
+
+
+健康状态
+
+ 未加载
+
+
+
+
+
+
项目
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+新建任务
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+任务列表 (每 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()