feat: web command console v1 (GET /console) (#57)
Co-authored-by: host <host@test.local>
This commit is contained in:
1 parent
c6e633d162
commit
e40c2c33bb
4 files changed
+441
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Codex 命令台</title>
|
||||
<!--
|
||||
命令台 v1:纯静态单页,API 用绝对路径,假定与控制面同源同根挂载
|
||||
(即本页由 server.py 的 /console 直接 serve)。数据一律凭用户在
|
||||
本页输入的 API key 调接口;key 只存在本浏览器 localStorage,
|
||||
不经过任何第三方。页面本身不走鉴权(只是个壳,没有 key 时
|
||||
调不到任何数据);日后若经 nginx 子路径挂载,需改 API 前缀。
|
||||
-->
|
||||
<style>
|
||||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0 auto; max-width: 1100px; padding: 16px; color: #222; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 16px; margin: 24px 0 8px; }
|
||||
.muted { color: #666; font-size: 13px; }
|
||||
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
|
||||
input, select, textarea, button { font-size: 14px; padding: 6px 8px; }
|
||||
input[type="text"], input[type="password"] { min-width: 260px; }
|
||||
textarea { width: 100%; min-height: 70px; box-sizing: border-box; }
|
||||
button { cursor: pointer; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; vertical-align: top; }
|
||||
th { background: #f5f5f5; }
|
||||
tr { cursor: pointer; }
|
||||
tr.selected { background: #eef6ff; }
|
||||
.st-queued { color: #666; } .st-running { color: #1565c0; font-weight: bold; }
|
||||
.st-completed { color: #2e7d32; } .st-failed, .st-merge-failed { color: #c62828; }
|
||||
.st-merged { color: #6a1b9a; } .st-interrupted { color: #e65100; }
|
||||
pre { background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px; white-space: pre-wrap; word-break: break-word; }
|
||||
.err { color: #c62828; }
|
||||
.ok { color: #2e7d32; }
|
||||
.row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.row > div { flex: 1; min-width: 300px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Codex 命令台</h1>
|
||||
<p class="muted">控制面操作台 v1:任务是核心操作对象;成本、workers、健康状态附带查看。公网入口未开时经 SSH 隧道访问本机 8765/8766 端口。</p>
|
||||
|
||||
<h2>API Key</h2>
|
||||
<div class="bar">
|
||||
<input id="key" type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autocomplete="off">
|
||||
<button id="saveKey">保存到本浏览器</button>
|
||||
<button id="clearKey">清除</button>
|
||||
<span id="keyState" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<h2>健康状态</h2>
|
||||
<div class="bar">
|
||||
<span id="health" class="muted">未加载</span>
|
||||
<button id="refreshHealth">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<h2>项目</h2>
|
||||
<div class="bar">
|
||||
<select id="projectSelect"><option value="">(先加载项目)</option></select>
|
||||
<button id="refreshProjects">刷新</button>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<input id="newProjectName" type="text" placeholder="新项目名">
|
||||
<input id="newProjectRepo" type="text" placeholder="repo_url(可选)">
|
||||
<button id="createProject">新建项目</button>
|
||||
</div>
|
||||
<span id="projectMsg" class="muted"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Workers</h2>
|
||||
<div class="bar"><button id="refreshWorkers">刷新</button></div>
|
||||
<div id="workers" class="muted">未加载</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>新建任务</h2>
|
||||
<div class="bar">
|
||||
<select id="executor">
|
||||
<option value="">executor:任意 worker</option>
|
||||
<option value="local">local</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="cloud">cloud</option>
|
||||
</select>
|
||||
<label><input id="goalMode" type="checkbox"> goal 模式</label>
|
||||
</div>
|
||||
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
|
||||
<div id="goalFields" style="display:none">
|
||||
<input id="goal" type="text" placeholder="goal(缺省用 prompt)" style="width:100%; box-sizing:border-box; margin:6px 0;">
|
||||
<input id="verifyCommand" type="text" placeholder="verify_command 验收命令(goal 模式必填项按服务端规则)" style="width:100%; box-sizing:border-box; margin:6px 0;">
|
||||
<label>轮次 <input id="goalRounds" type="number" value="3" min="1" style="width:60px;"></label>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button id="createTask">提交任务</button>
|
||||
<span id="taskMsg" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
|
||||
<div class="bar">
|
||||
<button id="refreshTasks">刷新</button>
|
||||
<span id="taskCount" class="muted"></span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>prompt</th><th>状态</th><th>executor</th><th>主机</th><th>PR</th><th>更新</th></tr></thead>
|
||||
<tbody id="taskRows"><tr><td colspan="7" class="muted">先选项目</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<h2>任务详情</h2>
|
||||
<div id="detail" class="muted">点任务行查看详情</div>
|
||||
<div class="bar" id="detailActions" style="display:none">
|
||||
<button id="btnRetry">重试</button>
|
||||
<button id="btnCancel">取消</button>
|
||||
<button id="btnMerge">合并(completed → merged)</button>
|
||||
<button id="btnPr">提 PR(须 merged)</button>
|
||||
<button id="btnCost">查成本</button>
|
||||
<span id="actionMsg" class="muted"></span>
|
||||
</div>
|
||||
<pre id="detailBody" style="display:none"></pre>
|
||||
<div id="costBox"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
var KEY_STORE = "codex-console-key";
|
||||
var currentTask = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function fmt(ts) {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
function shortId(id) { return id ? id.slice(0, 8) : ""; }
|
||||
function getKey() { return localStorage.getItem(KEY_STORE) || ""; }
|
||||
function setKeyState() {
|
||||
$("#keyState").textContent = getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401";
|
||||
}
|
||||
|
||||
async function api(path, method, body) {
|
||||
var headers = { "Content-Type": "application/json" };
|
||||
var key = getKey();
|
||||
if (key) headers["X-API-Key"] = key;
|
||||
var opt = { method: method || "GET", headers: headers };
|
||||
if (body !== undefined) opt.body = JSON.stringify(body);
|
||||
var r = await fetch(path, opt);
|
||||
var data = null;
|
||||
try { data = await r.json(); } catch (e) { data = null; }
|
||||
if (!r.ok) {
|
||||
var msg = (data && data.error) ? data.error : ("HTTP " + r.status);
|
||||
if (r.status === 401) msg += "(key 未填或无效)";
|
||||
if (r.status === 429) msg += "(触发限流,稍后再试)";
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
var h = await api("/health", "GET");
|
||||
$("#health").textContent = "backend=" + h.backend + " db_ok=" + h.db_ok +
|
||||
" worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7);
|
||||
} catch (e) { $("#health").textContent = "加载失败:" + e.message; }
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
var d = await api("/projects", "GET");
|
||||
var sel = $("#projectSelect");
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = "";
|
||||
(d.projects || []).forEach(function (p) {
|
||||
var o = document.createElement("option");
|
||||
o.value = p.id; o.textContent = p.name + " (" + shortId(p.id) + ")";
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur; })) sel.value = cur;
|
||||
$("#projectMsg").textContent = (d.projects || []).length + " 个项目";
|
||||
} catch (e) { $("#projectMsg").textContent = "加载失败:" + e.message; }
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
var d = await api("/workers", "GET");
|
||||
var list = d.workers || d || [];
|
||||
if (!list.length) { $("#workers").textContent = "无 worker 注册"; return; }
|
||||
$("#workers").innerHTML = list.map(function (w) {
|
||||
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
|
||||
" / capacity " + esc(w.capacity || "") +
|
||||
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
|
||||
}).join("<br>");
|
||||
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='muted'>先选项目</td></tr>"; return; }
|
||||
try {
|
||||
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
|
||||
var tasks = d.tasks || [];
|
||||
$("#taskCount").textContent = tasks.length + " 个任务";
|
||||
if (!tasks.length) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='muted'>该项目暂无任务</td></tr>"; return; }
|
||||
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
|
||||
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
|
||||
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
|
||||
"<td>" + esc(shortId(t.id)) + "</td>" +
|
||||
"<td>" + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
|
||||
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
|
||||
"<td>" + esc(t.executor || "") + "</td>" +
|
||||
"<td>" + esc(t.worktree_host || "") + "</td>" +
|
||||
"<td>" + pr + "</td>" +
|
||||
"<td>" + esc(fmt(t.updated_at)) + "</td></tr>";
|
||||
}).join("");
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) {
|
||||
tr.onclick = function () { showDetail(tr.getAttribute("data-id")); };
|
||||
});
|
||||
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
|
||||
}
|
||||
|
||||
async function showDetail(taskId) {
|
||||
try {
|
||||
var t = await api("/tasks/" + encodeURIComponent(taskId), "GET");
|
||||
currentTask = t;
|
||||
document.querySelectorAll("#taskRows tr").forEach(function (tr) {
|
||||
tr.classList.toggle("selected", tr.getAttribute("data-id") === taskId);
|
||||
});
|
||||
$("#detail").innerHTML =
|
||||
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b> | " +
|
||||
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
|
||||
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
|
||||
"<br>prompt:<br>" + esc(t.prompt || "");
|
||||
var body = "";
|
||||
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
|
||||
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
|
||||
if (!body) body = "(暂无 result/error 输出)";
|
||||
var pre = $("#detailBody");
|
||||
pre.textContent = body;
|
||||
pre.style.display = "block";
|
||||
$("#detailActions").style.display = "flex";
|
||||
$("#actionMsg").textContent = "";
|
||||
$("#costBox").innerHTML = "";
|
||||
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
async function doAction(kind) {
|
||||
if (!currentTask) return;
|
||||
var confirmNeeded = { cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)" };
|
||||
if (confirmNeeded[kind] && !confirm(confirmNeeded[kind])) return;
|
||||
try {
|
||||
var r = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/" + kind, "POST", {});
|
||||
$("#actionMsg").innerHTML = "<span class='ok'>" + kind + " 已提交:" + esc(JSON.stringify(r).slice(0, 300)) + "</span>";
|
||||
loadTasks();
|
||||
showDetail(currentTask.id);
|
||||
} catch (e) { $("#actionMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
$("#saveKey").onclick = function () {
|
||||
var v = $("#key").value.trim();
|
||||
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
|
||||
localStorage.setItem(KEY_STORE, v);
|
||||
$("#key").value = "";
|
||||
setKeyState();
|
||||
};
|
||||
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
|
||||
$("#refreshHealth").onclick = loadHealth;
|
||||
$("#refreshProjects").onclick = loadProjects;
|
||||
$("#refreshWorkers").onclick = loadWorkers;
|
||||
$("#refreshTasks").onclick = loadTasks;
|
||||
$("#projectSelect").onchange = loadTasks;
|
||||
$("#goalMode").onchange = function () { $("#goalFields").style.display = this.checked ? "block" : "none"; };
|
||||
$("#createProject").onclick = async function () {
|
||||
var name = $("#newProjectName").value.trim();
|
||||
if (!name) { $("#projectMsg").textContent = "项目名必填"; return; }
|
||||
var body = { name: name };
|
||||
var repo = $("#newProjectRepo").value.trim();
|
||||
if (repo) body.repo_url = repo;
|
||||
try {
|
||||
await api("/projects", "POST", body);
|
||||
$("#projectMsg").innerHTML = "<span class='ok'>项目已建</span>";
|
||||
$("#newProjectName").value = ""; $("#newProjectRepo").value = "";
|
||||
loadProjects();
|
||||
} catch (e) { $("#projectMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
$("#createTask").onclick = async function () {
|
||||
var pid = $("#projectSelect").value;
|
||||
var prompt = $("#prompt").value.trim();
|
||||
if (!pid) { $("#taskMsg").textContent = "先选项目"; return; }
|
||||
if (!prompt) { $("#taskMsg").textContent = "prompt 必填"; return; }
|
||||
var body = { project_id: pid, prompt: prompt };
|
||||
var ex = $("#executor").value;
|
||||
if (ex) body.executor = ex;
|
||||
if ($("#goalMode").checked) {
|
||||
body.goal = $("#goal").value.trim() || prompt;
|
||||
body.verify_command = $("#verifyCommand").value.trim();
|
||||
body.goal_max_rounds = parseInt($("#goalRounds").value, 10) || 3;
|
||||
}
|
||||
try {
|
||||
var t = await api("/tasks", "POST", body);
|
||||
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
|
||||
$("#prompt").value = "";
|
||||
loadTasks();
|
||||
} catch (e) { $("#taskMsg").innerHTML = "<span class='err'>提交失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
$("#btnRetry").onclick = function () { doAction("retry"); };
|
||||
$("#btnCancel").onclick = function () { doAction("cancel"); };
|
||||
$("#btnMerge").onclick = function () { doAction("merge"); };
|
||||
$("#btnPr").onclick = function () { doAction("pr"); };
|
||||
$("#btnCost").onclick = async function () {
|
||||
if (!currentTask) return;
|
||||
try {
|
||||
var c = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/cost", "GET");
|
||||
$("#costBox").innerHTML = "<pre>" + esc(JSON.stringify(c, null, 2).slice(0, 4000)) + "</pre>";
|
||||
} catch (e) { $("#costBox").innerHTML = "<span class='err'>查成本失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
|
||||
setKeyState();
|
||||
loadHealth();
|
||||
loadProjects();
|
||||
loadWorkers();
|
||||
setInterval(function () { if ($("#projectSelect").value) loadTasks(); loadHealth(); }, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user