From 154c18e25d15c293dbebc52df23337b90bf139fb Mon Sep 17 00:00:00 2001 From: host Date: Tue, 27 Oct 2026 14:54:30 +0000 Subject: [PATCH 1/4] P2 console slice A: project members, admin panel, task filters, trace download, worker liveness - Members: GET/POST /projects/{id}/members, PATCH /projects/{id}/members/{user_id}; owner or CODEX_GLOBAL_ADMINS only, audit via set_member; invite by user_id or username, duplicate 409, sole-owner demotion 409. - Console admin panel reuses /admin/quotas, /admin/sweep, /admin/backup; admin probe reuses /admin/quotas 403-vs-400 ordering, no new endpoint. - Task list: frontend status/executor filters + id/prompt search over the full fetch; 10s refresh unchanged. - Trace: kind filter on render, download button fetches full /trace (not the 1000-row render truncation); workers show relative last_seen with the 120s alive threshold. - Tests: cloud/tests/test_p2_console.py, 15 tests. --- cloud/console.html | 317 +++++++++++++++++++++++++++++-- cloud/server.py | 181 ++++++++++++++++++ cloud/tests/test_p2_console.py | 335 +++++++++++++++++++++++++++++++++ 3 files changed, 816 insertions(+), 17 deletions(-) create mode 100644 cloud/tests/test_p2_console.py diff --git a/cloud/console.html b/cloud/console.html index 6e21687..d86e1e1 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -90,6 +90,22 @@
未加载
+
+

管理区 (仅全局管理员)

+
填 key 后判断权限…
+ +
+

健康状态

@@ -112,6 +128,20 @@
+

项目成员

+
+ + + + +
+
先选项目
+

Workers

@@ -198,6 +228,23 @@

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

+ + +
@@ -273,6 +320,22 @@
点任务行查看详情后,点“刷新队列”查看评审记录

实时链路

+
+ + + +
点任务行查看详情后,这里显示过程链路

后续消息

@@ -484,6 +547,20 @@ async function decideApproval(approvalId, decision) { loadApprovals(currentTask.id); } catch (e) { $("#approvalMsg").innerHTML = "决定失败:" + esc(e.message) + ""; } } +function workerAgeText(w) { + // last_seen 是 epoch 秒;服务端也可能只给距今秒数(旧字段)。 + // 返回 {text, age}:age 为 null 时无法判断在线状态。 + var now = Date.now() / 1000; + var age = null; + if (typeof w.last_seen === "number") age = now - w.last_seen; + else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago; + if (age === null || age < 0) age = null; + if (age === null) return { text: "未知", age: null }; + var s = Math.floor(age); + if (s < 60) return { text: s + " 秒前", age: age }; + if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age: age }; + return { text: Math.floor(s / 3600) + " 小时前", age: age }; +} async function loadWorkers() { try { var d = await api("/workers", "GET"); @@ -499,10 +576,16 @@ async function loadWorkers() { $("#workers").innerHTML = list.map(function (w) { var run = (w.running === undefined || w.running === null) ? "?" : w.running; var cap = (w.capacity === undefined || w.capacity === null) ? "?" : w.capacity; - var live = (w.alive === false) ? " / 离线(占用为上次上报值)" : ""; + // 在线判定:优先服务端 alive(阈值 120 秒,与服务端 + // alive_threshold_seconds 默认值一致),缺失时按 120 秒自算。 + var info = workerAgeText(w); + var alive = (w.alive !== undefined && w.alive !== null) + ? !!w.alive + : (info.age !== null && info.age <= 120); + var live = alive ? " / 在线" : " / 离线(占用为上次上报值)"; return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") + " / 占用 " + esc(run) + "/" + esc(cap) + live + - " / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || ""); + " / 最后心跳 " + esc(info.text); }).join("
"); } catch (e) { $("#workers").textContent = "加载失败:" + e.message; } } @@ -566,15 +649,33 @@ async function loadEnvironments() { } } -async function loadTasks() { - var pid = $("#projectSelect").value; - if (!pid) { $("#taskRows").innerHTML = "先选项目"; 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 = "该项目暂无任务"; return; } - $("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) { +var tasksCache = []; +function filteredTasks() { + // 筛选与搜索是纯前端逻辑:10 秒刷新仍拉全量,筛选条件 + // 不随刷新丢失(不改服务端 /tasks 查询语义)。 + var st = $("#taskStatusFilter") ? $("#taskStatusFilter").value : ""; + var ex = $("#taskExecutorFilter") ? $("#taskExecutorFilter").value : ""; + var q = $("#taskSearch") ? $("#taskSearch").value.trim().toLowerCase() : ""; + return tasksCache.filter(function (t) { + if (st && t.status !== st) return false; + if (ex && (t.executor || "") !== ex) return false; + if (q) { + var hay = ((t.id || "") + " " + (t.prompt || "")).toLowerCase(); + if (hay.indexOf(q) < 0) return false; + } + return true; + }); +} +function renderTasks() { + var tasks = filteredTasks(); + var total = tasksCache.length; + $("#taskCount").textContent = tasks.length + " 个任务(共 " + total + " 个)"; + if (!tasks.length) { + $("#taskRows").innerHTML = "" + + (total ? "没有符合筛选条件的任务" : "该项目暂无任务") + ""; + return; + } + $("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) { var pr = t.pr_url ? "#" + esc(t.pr_number) + "" : ""; return "" + "" + esc(shortId(t.id)) + "" + @@ -590,6 +691,14 @@ async function loadTasks() { Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) { tr.onclick = function () { showDetail(tr.getAttribute("data-id")); }; }); +} +async function loadTasks() { + var pid = $("#projectSelect").value; + if (!pid) { $("#taskRows").innerHTML = "先选项目"; return; } + try { + var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET"); + tasksCache = d.tasks || []; + renderTasks(); } catch (e) { $("#taskRows").innerHTML = "加载失败:" + esc(e.message) + ""; } } @@ -612,19 +721,51 @@ function traceLine(e) { else body = esc(JSON.stringify(e).slice(0, 300)); return "[" + k + "] " + body; } +var lastTraceEntries = []; +var lastTraceNote = ""; +var lastTraceDone = ""; function renderTrace(entries, note, doneStatus) { + // 全量条目先存下来供“下载完整链路”用;屏上显示仍截断 + // 最近 TRACE_LIMIT 条,再按类型过滤(过滤不影响下载内容)。 + lastTraceEntries = entries || []; + lastTraceNote = note || ""; + lastTraceDone = doneStatus || ""; var box = $("#traceBox"); var html = ""; if (note) html += "" + esc(note) + "
"; - if (!entries.length) html += "(暂无链路条目)"; + var kind = $("#traceKindFilter") ? $("#traceKindFilter").value : ""; + var pool = kind ? lastTraceEntries.filter(function (e) { return e.kind === kind; }) : lastTraceEntries; + if (!pool.length) html += kind ? "(该类型暂无链路条目)" : "(暂无链路条目)"; else { - var shown = entries.slice(-TRACE_LIMIT); - if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)
"; + var shown = pool.slice(-TRACE_LIMIT); + if (pool.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + pool.length + " 条)
"; + else if (kind) html += "(按类型 " + esc(kind) + " 过滤,共 " + pool.length + " 条)
"; html += shown.map(traceLine).join("
"); } if (doneStatus) html += "
链路已结束(" + esc(doneStatus) + ")"; box.innerHTML = html; } +async function downloadTrace() { + // 下载走同一 /trace 端点全量拉取,不用屏上截断后的条目: + // 屏上是最近 1000 条 + 类型过滤,下载必须是完整链路。 + if (!currentTask) { $("#traceMsg").textContent = "先选一个任务"; return; } + try { + var d = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/trace", "GET"); + var entries = d.entries || []; + var blob = new Blob([JSON.stringify( + { task_id: currentTask.id, status: d.status, note: d.note || "", + count: entries.length, entries: entries }, null, 2)], + { type: "application/json" }); + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "trace-" + currentTask.id.slice(0, 8) + ".json"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + $("#traceMsg").innerHTML = "已下载完整链路 " + entries.length + " 条"; + } catch (e) { $("#traceMsg").innerHTML = "下载失败:" + esc(e.message) + ""; } +} var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" }; var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" }; var AGENT_CALL_SHOWN = 50; @@ -985,6 +1126,136 @@ async function postFollowup(mode) { } catch (e) { $("#followupMsg").innerHTML = "发送失败:" + esc(e.message) + ""; } } +function memberErr(e) { + var m = e.message || ""; + if (m.indexOf("forbidden") >= 0) return "没有权限:只有项目 owner 或全局管理员可以管理成员"; + if (m.indexOf("already_member") >= 0) return "该用户已是成员,改角色请用成员行的下拉框"; + if (m.indexOf("cannot demote the only owner") >= 0) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner"; + if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对 user_id 或用户名是否正确"; + if (m.indexOf("member not found") >= 0) return "该用户不是本项目成员"; + if (m.indexOf("unknown role") >= 0) return "角色无效:只能是 viewer / contributor / maintainer / owner"; + if (m.indexOf("project not found") >= 0) return "项目不存在(可能已被删除)"; + return "成员操作失败:" + m; +} +async function loadMembers() { + var pid = $("#projectSelect").value; + var box = $("#memberList"); + if (!pid) { box.textContent = "先选项目"; return; } + try { + var d = await api("/projects/" + encodeURIComponent(pid) + "/members", "GET"); + var list = d.members || []; + if (!list.length) { box.textContent = "该项目暂无成员"; return; } + box.innerHTML = list.map(function (m) { + var label = esc(m.username || "") + (m.username ? "(" + esc(String(m.user_id).slice(0, 8)) + ")" : esc(String(m.user_id).slice(0, 8))); + var opts = ["viewer", "contributor", "maintainer", "owner"].map(function (r) { + return ""; + }).join(""); + return "
" + label + " |
"; + }).join(""); + Array.prototype.forEach.call(box.querySelectorAll("[data-member-role]"), function (sel) { + var prev = sel.value; + sel.onchange = async function () { + var uid = sel.getAttribute("data-member-role"); + var role = sel.value; + try { + await api("/projects/" + encodeURIComponent(pid) + "/members/" + encodeURIComponent(uid), "PATCH", { role: role }); + prev = role; + $("#memberMsg").innerHTML = "角色已改为 " + esc(role) + ""; + } catch (e) { + sel.value = prev; + $("#memberMsg").innerHTML = "" + esc(memberErr(e)) + ""; + } + }; + }); + } catch (e) { box.innerHTML = "成员加载失败:" + esc(memberErr(e)) + ""; } +} +async function inviteMember() { + var pid = $("#projectSelect").value; + if (!pid) { $("#memberMsg").textContent = "先选项目"; return; } + var raw = $("#inviteUser").value.trim(); + if (!raw) { $("#memberMsg").textContent = "请填成员 user_id 或用户名"; return; } + // 输入可能是 user_id 也可能是用户名:服务端优先按 user_id + // 查,查不到时前端再按 username 试一次(避免把用户名误当 id 报 404)。 + var body = { user_id: raw, role: $("#inviteRole").value }; + try { + await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", body); + } catch (e) { + if (e.message.indexOf("user not found") >= 0) { + try { + await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", { username: raw, role: $("#inviteRole").value }); + } catch (e2) { $("#memberMsg").innerHTML = "" + esc(memberErr(e2)) + ""; return; } + } else { + $("#memberMsg").innerHTML = "" + esc(memberErr(e)) + ""; + return; + } + } + $("#memberMsg").innerHTML = "已邀请成员"; + $("#inviteUser").value = ""; + loadMembers(); +} +function adminErr(e) { + var m = e.message || ""; + if (m.indexOf("forbidden") >= 0) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员"; + if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对用户 id 是否正确"; + return "管理操作失败:" + m; +} +async function probeAdmin() { + // 权限探针复用已有 /admin/quotas:服务端先验全局管理员 + // (非管理员 403),通过后才校验 user_id 参数(缺参数 400)。 + // 故 400 = 是管理员、403 = 不是管理员,不新增任何端点。 + var gate = $("#adminGate"); + var body = $("#adminBody"); + if (!getKey()) { gate.textContent = "未填 key,管理区不可用"; body.style.display = "none"; return; } + try { + var headers = { "X-API-Key": getKey() }; + var r = await fetch("/admin/quotas", { headers: headers }); + if (r.status === 400) { + gate.innerHTML = "已确认为全局管理员,管理操作可用"; + body.style.display = "block"; + } else if (r.status === 403) { + gate.textContent = "管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)"; + body.style.display = "none"; + } else if (r.status === 401) { + gate.textContent = "key 无效,管理区不可用(服务端返回 401)"; + body.style.display = "none"; + } else { + gate.textContent = "管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用"; + body.style.display = "none"; + } + } catch (e) { gate.textContent = "管理权限判断失败:" + e.message; body.style.display = "none"; } +} +function fmtUsd(micros) { + if (micros === null || micros === undefined) return "不限额"; + return "$" + (micros / 1000000).toFixed(2); +} +async function adminQuota() { + var uid = $("#adminUserId").value.trim(); + if (!uid) { $("#adminResult").textContent = "请填要查配额的用户 id"; return; } + try { + var d = await api("/admin/quotas?user_id=" + encodeURIComponent(uid), "GET"); + $("#adminResult").innerHTML = "用户 " + esc(uid.slice(0, 8)) + ":本月用量 " + esc(fmtUsd(d.used_usd_micros)) + + " / 配额 " + esc(fmtUsd(d.quota_usd_micros)) + + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + esc(fmtUsd(d.remaining_usd_micros)) + ")" : "") + + " | 月份 " + esc(d.month || ""); + } catch (e) { $("#adminResult").innerHTML = "" + esc(adminErr(e)) + ""; } +} +async function adminSweep() { + if (!confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return; + try { + var d = await api("/admin/sweep", "POST", {}); + var swept = d.swept || []; + $("#adminResult").innerHTML = "sweep 完成,清理 " + swept.length + " 个任务" + + (swept.length ? ":" + esc(swept.join("、")) : "") + ""; + } catch (e) { $("#adminResult").innerHTML = "" + esc(adminErr(e)) + ""; } +} +async function adminBackup() { + try { + var d = await api("/admin/backup", "POST", {}); + $("#adminResult").innerHTML = "备份完成:backend=" + esc(d.backend) + + " | 路径 " + esc(d.path) + " | 大小 " + esc(d.size_bytes) + " 字节 | 任务 " + esc(d.task_count) + " 行"; + } catch (e) { $("#adminResult").innerHTML = "" + esc(adminErr(e)) + ""; } +} + $("#saveKey").onclick = function () { var v = $("#key").value.trim(); if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; } @@ -996,18 +1267,29 @@ $("#saveKey").onclick = function () { loadHealth(); loadProjects(); loadWorkers(); - if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } + if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); loadMembers(); } if (getKey()) loadRelayModels(); loadQuota(); loadGithubRepos(); + probeAdmin(); }; -$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); }; +$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); probeAdmin(); }; $("#refreshHealth").onclick = loadHealth; $("#refreshProjects").onclick = loadProjects; $("#refreshWorkers").onclick = loadWorkers; $("#refreshTasks").onclick = loadTasks; $("#refreshEnvironments").onclick = loadEnvironments; -$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); }; +$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); }; +$("#refreshMembers").onclick = loadMembers; +$("#inviteMember").onclick = inviteMember; +$("#taskStatusFilter").onchange = renderTasks; +$("#taskExecutorFilter").onchange = renderTasks; +$("#taskSearch").oninput = renderTasks; +$("#traceKindFilter").onchange = function () { renderTrace(lastTraceEntries, lastTraceNote, lastTraceDone); }; +$("#btnDownloadTrace").onclick = downloadTrace; +$("#btnAdminQuota").onclick = adminQuota; +$("#btnAdminSweep").onclick = adminSweep; +$("#btnAdminBackup").onclick = adminBackup; $("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; }; $("#createProject").onclick = async function () { var name = $("#newProjectName").value.trim(); @@ -1240,6 +1522,7 @@ loadGithubRepos(); // 否则下拉永远只有默认项(此前只在点“保存”时调一次, // 2026-10-27 实测:存过 key 后重开页面模型选不了)。 if (getKey()) loadRelayModels(); +probeAdmin(); setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000); diff --git a/cloud/server.py b/cloud/server.py index 4e77fd3..e426581 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -55,6 +55,7 @@ from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, quote, urlparse +from cloud.auth import ROLES as _MEMBER_ROLES from cloud.auth import AuthStore from cloud.coordinator import parse_parts as _coord_parse_parts from cloud.coordinator import validate_parts as _coord_validate_parts @@ -2077,6 +2078,170 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): projects.append(item) self._send_json(200, {"projects": projects}) + # -- 项目成员:邀请 / 列表 / 改角色 ------------------------------- + def _can_manage_members(self, user_id, project_id): + """成员管理权:项目 owner 或全局管理员。 + + 全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员, + 故不能只用项目内 manage_project 判定;项目 owner 判定 + 复用 check_permission 的 manage_project(仅 owner)。 + """ + if self._is_global_admin(user_id): + return True + with _auth_store(self.server.db_path) as store: + return store.check_permission( + user_id, project_id, "manage_project") + + def _set_member_row(self, store, project_id, actor_id, target_id, + role): + """写成员行并记审计:owner 走 AuthStore.set_member(内含 + owner 校验与审计);非成员的全局管理员走直接写 + 同格式 + 审计(set_member 的 owner 校验会挡住全局管理员,故分流)。 + """ + if store.check_permission(actor_id, project_id, + "manage_project"): + store.set_member(project_id, actor_id, target_id, role) + return + store.conn.execute( + "INSERT OR REPLACE INTO project_members" + "(project_id, user_id, role) VALUES(?, ?, ?)", + (project_id, target_id, role)) + store.conn.commit() + store.audit(actor_id, project_id, "set_member", + "%s:%s" % (target_id, role)) + + def _project_members_list(self, user_id, project_id): + """GET /projects/{id}/members:项目成员(read)或全局 + 管理员可读。只返回 user_id / username / role,绝不返回 + key 哈希等认证字段。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._is_global_admin(user_id): + err = self._check_project(user_id, project_id, "read") + if err: + self._send_json(err[0], {"error": err[1]}) + return + conn = self._db() + try: + rows = conn.execute( + "SELECT m.user_id, m.role, u.username FROM" + " project_members m LEFT JOIN users u" + " ON u.id = m.user_id WHERE m.project_id = ?" + " ORDER BY u.username, m.user_id", + (project_id,)).fetchall() + finally: + conn.close() + self._send_json(200, {"project_id": project_id, "members": [ + {"user_id": r["user_id"], "username": r["username"], + "role": r["role"]} for r in rows]}) + + def _resolve_invite_target(self, body): + """邀请目标解析:优先 user_id,其次 username 查 id; + 返回 (target_id, error_status, error)。""" + target = body.get("user_id") + username = body.get("username") + if target is not None and not isinstance(target, str): + return None, 400, "user_id must be a string" + if username is not None and not isinstance(username, str): + return None, 400, "username must be a string" + if not target and not username: + return None, 400, "user_id or username required" + conn = self._db() + try: + if target: + row = conn.execute( + "SELECT id FROM users WHERE id = ?", + (target,)).fetchone() + if row is None: + return None, 404, "user not found" + return row["id"], None, None + row = conn.execute( + "SELECT id FROM users WHERE username = ?", + (username,)).fetchone() + if row is None: + return None, 404, "user not found" + return row["id"], None, None + finally: + conn.close() + + def _project_member_invite(self, user_id, project_id): + """POST /projects/{id}/members:限项目 owner 或全局管理 + 员。已是成员时 409(改角色走 PATCH,不用邀请覆盖); + 成功记 set_member 审计。role 缺省 contributor。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._can_manage_members(user_id, project_id): + self._send_json(403, {"error": "forbidden"}) + return + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + role = body.get("role") or "contributor" + if role not in _MEMBER_ROLES: + self._send_json(400, {"error": "unknown role"}) + return + target, estatus, eerr = self._resolve_invite_target(body) + if eerr: + self._send_json(estatus, {"error": eerr}) + return + with _auth_store(self.server.db_path) as store: + existing = store.conn.execute( + "SELECT role FROM project_members" + " WHERE project_id = ? AND user_id = ?", + (project_id, target)).fetchone() + if existing is not None: + self._send_json(409, {"error": "already_member"}) + return + self._set_member_row(store, project_id, user_id, + target, role) + self._send_json(201, {"project_id": project_id, + "user_id": target, "role": role}) + + def _project_member_patch(self, user_id, project_id, target): + """PATCH /projects/{id}/members/{user_id}:限项目 owner + 或全局管理员。唯一 owner 降为非 owner 时 409(无论降 + 自己还是降他人——否则项目将无 owner 可管);目标非成员 + 时 404。""" + if not self._project_exists(project_id): + self._send_json(404, {"error": "project not found"}) + return + if not self._can_manage_members(user_id, project_id): + self._send_json(403, {"error": "forbidden"}) + return + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + role = body.get("role") + if role not in _MEMBER_ROLES: + self._send_json(400, {"error": "unknown role"}) + return + with _auth_store(self.server.db_path) as store: + cur = store.conn.execute( + "SELECT role FROM project_members" + " WHERE project_id = ? AND user_id = ?", + (project_id, target)).fetchone() + if cur is None: + self._send_json(404, {"error": "member not found"}) + return + if cur[0] == "owner" and role != "owner": + owners = store.conn.execute( + "SELECT COUNT(*) FROM project_members" + " WHERE project_id = ? AND role = 'owner'", + (project_id,)).fetchone()[0] + if owners <= 1: + self._send_json( + 409, + {"error": "cannot demote the only owner"}) + return + self._set_member_row(store, project_id, user_id, + target, role) + self._send_json(200, {"project_id": project_id, + "user_id": target, "role": role}) + # -- 项目子资源:记忆 / 知识库 / 成本 ------------------------------- def _project_exists(self, project_id): conn = self._db() @@ -2107,6 +2272,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): if len(parts) == 1 and method == "PATCH": self._update_project(user_id, project_id) return + # /projects/{id}/members:GET 列表 / POST 邀请 + if len(parts) == 2 and parts[1] == "members": + if method == "GET": + self._project_members_list(user_id, project_id) + elif method == "POST": + self._project_member_invite(user_id, project_id) + else: + self._send_json(404, {"error": "not found"}) + return + # /projects/{id}/members/{user_id}:PATCH 改角色 + if len(parts) == 3 and parts[1] == "members": + if method == "PATCH": + self._project_member_patch(user_id, project_id, parts[2]) + else: + self._send_json(404, {"error": "not found"}) + return # /projects/{id}/memory if len(parts) == 2 and parts[1] == "memory": if method == "POST": diff --git a/cloud/tests/test_p2_console.py b/cloud/tests/test_p2_console.py new file mode 100644 index 0000000..ab5e3bd --- /dev/null +++ b/cloud/tests/test_p2_console.py @@ -0,0 +1,335 @@ +"""P2 切片 A(控制台管理面与筛选)单测。 + +只覆盖本切片新增或复用的服务端契约 + 控制台壳标记,不跑 +浏览器 JS(与 test_console.py 同款:壳只验标记与路由): +- 成员端点:非 owner 邀请 403、owner 邀请成功(user_id 与 + username 两种入参)、重复邀请 409、非法角色 400、未知用户 + 404、成员列表读权限、改角色权限与唯一 owner 防降级、 + 非成员全局管理员可邀请/改角色。 +- 管理区复用端点:非管理员 sweep 403、管理员配额读成功、 + 探针语义(非管理员 GET /admin/quotas 无参 403,管理员 400)。 +- 链路全量:写 1200 条链路文件,GET /trace 全量返回(控制台 + “下载完整链路”依赖的正是该端点,而非屏上截断的 1000 条)。 +- Workers:心跳后 /workers 返回 epoch 秒 last_seen 与 alive。 +- 控制台壳含本切片新增标记。 + +测试用的 key、用户 id 全是假值;不联网、不碰生产。 +""" + +import http.client +import json +import os +import sys +import tempfile +import threading +import time +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 + + +def _req(port, method, path, body=None, key=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + headers = {"Content-Type": "application/json"} + if key: + headers["X-API-Key"] = key + conn.request(method, path, + json.dumps(body) if body is not None else None, headers) + resp = conn.getresponse() + raw = resp.read() + conn.close() + try: + data = json.loads(raw) + except ValueError: + data = None + return resp.status, data + + +class P2ConsoleTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.trace_dir = os.path.join(self.tmp.name, "host-traces") + os.makedirs(self.trace_dir, exist_ok=True) + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.maint_id, self.maint_key = self.auth.create_user("maint") + self.member_id, self.member_key = self.auth.create_user("member") + self.admin_id, self.admin_key = self.auth.create_user("admin") + self.outsider_id, self.out_key = self.auth.create_user("outsider") + self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS") + # 全局管理员:非项目成员的 admin,验“owner 或全局管理员”双通道。 + os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.server.trace_dir = self.trace_dir + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, proj = _req(self.port, "POST", "/projects", + {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201, proj) + self.project_id = proj["id"] + self.auth.set_member(self.project_id, self.owner_id, + self.maint_id, "maintainer") + + 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() + if self._old_admins is None: + os.environ.pop("CODEX_GLOBAL_ADMINS", None) + else: + os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins + + def _members(self, key=None): + return _req(self.port, "GET", + "/projects/%s/members" % self.project_id, + key=key) + + # -- 成员邀请 / 列表 ------------------------------------------- + def test_non_owner_invite_forbidden(self): + # maintainer 不是 owner、不是全局管理员:邀请必须 403, + # 且安全门不能因“项目成员”身份放行。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.maint_key) + self.assertEqual(status, 403, data) + + def test_outsider_invite_forbidden(self): + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.out_key) + self.assertEqual(status, 403, data) + + def test_owner_invite_by_user_id_and_username(self): + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "contributor"}, + self.owner_key) + self.assertEqual(status, 201, data) + self.assertEqual(data["role"], "contributor") + # 第二个用户走 username 入参(控制台输入框两种都接受)。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"username": "outsider", "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 201, data) + self.assertEqual(data["user_id"], self.outsider_id) + + def test_invite_validation(self): + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "superuser"}, + self.owner_key) + self.assertEqual(status, 400) + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": "no-such-user", "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 404) + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"role": "viewer"}, + self.owner_key) + self.assertEqual(status, 400) + + def test_invite_duplicate_conflict(self): + status, _ = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + self.assertEqual(status, 201) + # 已是成员:改角色走 PATCH,重复邀请 409,不静默覆盖。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "maintainer"}, + self.owner_key) + self.assertEqual(status, 409, data) + + def test_member_list_read_and_outsider_denied(self): + _req(self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + status, data = self._members(self.maint_key) + self.assertEqual(status, 200, data) + roles = {m["user_id"]: m["role"] for m in data["members"]} + self.assertEqual(roles[self.owner_id], "owner") + self.assertEqual(roles[self.maint_id], "maintainer") + self.assertEqual(roles[self.member_id], "viewer") + # 列表不泄露认证字段。 + for m in data["members"]: + self.assertNotIn("api_key_hash", m) + self.assertNotIn("key_hash", m) + status, _ = self._members(self.out_key) + self.assertEqual(status, 403) + + def test_global_admin_can_invite_without_membership(self): + # admin 不是项目成员,但凭 CODEX_GLOBAL_ADMINS 可邀请。 + status, data = _req( + self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "contributor"}, + self.admin_key) + self.assertEqual(status, 201, data) + + # -- 改角色 / 唯一 owner 防降级 --------------------------------- + def test_patch_role_by_owner_and_non_owner_denied(self): + _req(self.port, "POST", + "/projects/%s/members" % self.project_id, + {"user_id": self.member_id, "role": "viewer"}, + self.owner_key) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.member_id), + {"role": "maintainer"}, self.maint_key) + self.assertEqual(status, 403, data) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.member_id), + {"role": "maintainer"}, self.owner_key) + self.assertEqual(status, 200, data) + self.assertEqual(data["role"], "maintainer") + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, "ghost"), + {"role": "viewer"}, self.owner_key) + self.assertEqual(status, 404) + + def test_cannot_demote_only_owner(self): + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "maintainer"}, self.owner_key) + self.assertEqual(status, 409, data) + # 先把另一位成员升为 owner,之后降原 owner 才放行。 + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.maint_id), + {"role": "owner"}, self.owner_key) + self.assertEqual(status, 200) + status, data = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "maintainer"}, self.maint_key) + self.assertEqual(status, 200, data) + + def test_global_admin_patch_blocked_by_sole_owner_guard(self): + # 全局管理员也不能把唯一 owner 降掉(防项目无 owner 可管)。 + status, _ = _req( + self.port, "PATCH", + "/projects/%s/members/%s" % (self.project_id, self.owner_id), + {"role": "viewer"}, self.admin_key) + self.assertEqual(status, 409) + + # -- 管理区复用端点 --------------------------------------------- + def test_admin_probe_and_quota_reuse(self): + # 探针语义:控制台凭此区分管理员与否,不新增端点。 + status, _ = _req(self.port, "GET", "/admin/quotas", + key=self.maint_key) + self.assertEqual(status, 403) + status, _ = _req(self.port, "GET", "/admin/quotas", + key=self.admin_key) + self.assertEqual(status, 400) + status, data = _req( + self.port, "GET", + "/admin/quotas?user_id=%s" % self.owner_id, + key=self.admin_key) + self.assertEqual(status, 200, data) + self.assertIn("used_usd_micros", data) + status, _ = _req( + self.port, "GET", + "/admin/quotas?user_id=%s" % self.owner_id, + key=self.maint_key) + self.assertEqual(status, 403) + + def test_non_admin_sweep_forbidden(self): + status, _ = _req(self.port, "POST", "/admin/sweep", {}, + self.maint_key) + self.assertEqual(status, 403) + status, _ = _req(self.port, "POST", "/admin/backup", {}, + self.maint_key) + self.assertEqual(status, 403) + + # -- 链路全量 / Workers ------------------------------------------ + def test_trace_endpoint_returns_full_entries(self): + status, task = _req( + self.port, "POST", "/tasks", + {"project_id": self.project_id, "prompt": "trace probe"}, + self.owner_key) + self.assertEqual(status, 201, task) + task_id = task["id"] + path = os.path.join(self.trace_dir, task_id + ".jsonl") + with open(path, "w", encoding="utf-8") as f: + for i in range(1200): + f.write(json.dumps( + {"id": "e%d" % i, "kind": "message", + "text": "line %d" % i}) + "\n") + status, data = _req( + self.port, "GET", "/tasks/%s/trace" % task_id, + key=self.owner_key) + self.assertEqual(status, 200, data) + # 服务端不截断:控制台下载按钮拿到的就是这 1200 条全量, + # 屏上 1000 条截断只是渲染层行为。 + self.assertEqual(len(data["entries"]), 1200) + + def test_workers_last_seen_is_epoch_and_alive(self): + status, data = _req( + self.port, "POST", "/workers/heartbeat", + {"worker_id": "w-p2", "executor": "docker", + "capacity": 2, "running": 0}, + self.owner_key) + self.assertEqual(status, 200, data) + status, data = _req(self.port, "GET", "/workers", + key=self.owner_key) + self.assertEqual(status, 200, data) + workers = data["workers"] + self.assertEqual(len(workers), 1) + # 控制台“x 秒/分钟前 + 120 秒在线阈值”的输入:epoch 秒。 + self.assertIsInstance(workers[0]["last_seen"], (int, float)) + self.assertLess(abs(workers[0]["last_seen"] - time.time()), 60) + self.assertTrue(workers[0]["alive"]) + + # -- 控制台壳标记 ------------------------------------------------- + def test_console_contains_p2_markers(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + conn.request("GET", "/console") + resp = conn.getresponse() + text = resp.read().decode("utf-8") + conn.close() + self.assertEqual(resp.status, 200) + for marker in ("/members", "inviteMember", "memberList", + "adminBody", "/admin/sweep", "/admin/backup", + "/admin/quotas", "taskStatusFilter", + "taskExecutorFilter", "taskSearch", + "btnDownloadTrace", "traceKindFilter", + "workerAgeText", "last_seen"): + self.assertIn(marker, text, marker) + + +if __name__ == "__main__": + unittest.main() From ef532a38c251fbd4058a43018880142af56e75f5 Mon Sep 17 00:00:00 2001 From: host Date: Tue, 27 Oct 2026 07:09:29 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E7=BA=BF=E7=A8=8B=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=20skills/MCP=20=E7=AE=A1=E7=90=86=E9=9D=A2?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=EF=BC=88host=20CLI=20+=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=20API=20+=20=E6=8E=A7=E5=88=B6=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线程: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(比控制台门控窄,服务端报错原文展示)。 --- cloud/console.html | 169 ++++++++- cloud/server.py | 657 +++++++++++++++++++++++++++++++++ cloud/tests/test_console.py | 10 +- cloud/tests/test_skills_mcp.py | 337 +++++++++++++++++ cloud/tests/test_thread_ops.py | 430 +++++++++++++++++++++ host.py | 164 ++++++++ tests/test_host_thread_cli.py | 363 ++++++++++++++++++ 7 files changed, 2128 insertions(+), 2 deletions(-) create mode 100644 cloud/tests/test_skills_mcp.py create mode 100644 cloud/tests/test_thread_ops.py create mode 100644 tests/test_host_thread_cli.py diff --git a/cloud/console.html b/cloud/console.html index d86e1e1..9a688f6 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -150,6 +150,31 @@
+
+

技能与 MCP

+
反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。
+
+ + 先选项目 +
+

技能

+
先选项目
+

应用

+
先选项目
+

MCP 服务

+
先选项目
+
+ + +
+
+ + + +
+
+
+

环境 (选、建、管:当前项目的基准检出)

@@ -379,6 +404,19 @@

分诊收件箱

+

线程

+
点任务行查看详情后,这里显示线程元数据
+
+ + + + + + + +
+
+
限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。