P1 security batch: quota execution checks, cost accounting, goal/coordinator semantics, PR channel, orphan signature, control-plane and console hardening

- Docker quota watchdog + fail-closed quota checks, system tasks need real owner
- max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed
- goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution
- coordinator children inherit parent model/effort, orphans marked interrupted with audit
- PR jobs on docker workers return explicit needs_local_worker instead of silent empty
- worker code signature covers 6 files and is checked before claim, fail-closed
- sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
This commit is contained in:
host committed 2026-10-27 16:27:50 +00:00
1 parent d254240d84
commit 1402dc67fd
20 files changed
+2616 -179

No files matched your search

+79 -12
View File
@@ -160,7 +160,7 @@
</div>
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
<textarea id="coordParts" placeholder="协调员子任务 JSON(可选),留空为普通任务"></textarea>
<div class="muted">协调员格式:[{"name":"后端","prompt":"做后端"},{"name":"前端","prompt":"做前端"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。</div>
<div class="muted">协调员格式:[{"name":"后端","prompt":"做后端"},{"name":"前端","prompt":"做前端"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
<div class="bar">
<select id="modelSelect">
<option value="">模型:默认(跟随配置)</option>
@@ -265,10 +265,11 @@
<input id="reviewSha" placeholder="指定提交用:提交 sha" style="width:100%">
<textarea id="reviewInstructions" placeholder="自定义评审指令用:评审指令正文"></textarea>
<div class="bar">
<button id="btnCreateReview" class="primary" onclick="postReview()">发起评审</button>
<button id="btnCreateReview" class="primary" onclick="postReview()">登记评审需求</button>
<button onclick="if (currentTask) loadReviews(currentTask.id)">刷新队列</button>
<span id="reviewMsg" class="muted"></span>
</div>
<div class="muted">本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。</div>
</div>
<div id="reviewList" class="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
<h3>实时链路</h3>
@@ -286,7 +287,7 @@
<div id="followupList" class="muted"></div>
<h3>运行时审批队列</h3>
<div class="bar"><button id="btnRefreshApprovals" onclick="if(currentTask)loadApprovals(currentTask.id)">刷新审批队列</button><span id="approvalMsg" class="muted"></span></div>
<div class="muted">待审批项需人工点允许或拒绝;超时默认拒绝。本队列的决定会留回执;实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变。</div>
<div class="muted">待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。</div>
<div id="approvalList" class="muted"></div>
<h3>给其他任务发消息</h3>
<div id="peerMessageForm">
@@ -354,7 +355,12 @@ async function api(path, method, body) {
var msg = (data && data.error) ? data.error : ("HTTP " + r.status);
if (r.status === 401) msg += "(key 未填或无效)";
if (r.status === 429) msg += "(触发限流,稍后再试)";
if (r.status === 402) msg += "(本月配额已用完,联系管理员调整)";
if (r.status === 402) {
// 402 要区分两种满:服务端在响应里带哪个上限字段就是哪个满。
if (data && data.unpriced_token_cap !== undefined) msg += "(无价 token 已满,联系管理员调整)";
else if (data && data.error === "coordinator_quota_reserve_exceeded") msg = "协调员分工预留不足:剩余美元配额不够覆盖全部分工,子任务费用记父任务创建人账,整个父任务已被挡下(未建任何子任务)";
else msg += "(美元配额已满,联系管理员调整)";
}
throw new Error(msg);
}
return data;
@@ -394,13 +400,22 @@ async function loadQuota() {
try {
var d = await api("/users/me/quota", "GET");
var used = (d.used_usd_micros / 1000000).toFixed(2);
var usdLine;
if (d.quota_usd_micros === null || d.quota_usd_micros === undefined) {
el.textContent = "本月用量 $" + used + "(不限额)";
usdLine = "美元:本月用量 $" + used + "(不限额)";
} else {
var q = (d.quota_usd_micros / 1000000).toFixed(2);
var rem = (d.remaining_usd_micros / 1000000).toFixed(2);
el.textContent = "本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
usdLine = "美元:本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
}
// 两条进度并列:美元一条,无价 token 一条;402 时按哪条满区分报错。
var tokLine = "无价 token:";
if (d.unpriced_token_cap === undefined || d.unpriced_tokens_used === undefined) {
tokLine += "未返回(旧服务端)";
} else {
tokLine += d.unpriced_tokens_used + " / " + d.unpriced_token_cap + " tokens";
}
el.textContent = usdLine + " | " + tokLine;
} catch (e) { el.textContent = "配额加载失败:" + e.message; }
}
@@ -453,8 +468,8 @@ async function loadApprovals(taskId) {
}
var btns = "";
if (a.status === "pending") {
btns = " <button onclick=\"decideApproval('" + esc(a.id) + "', 'approved')\">允许</button>" +
" <button onclick=\"decideApproval('" + esc(a.id) + "', 'denied')\">拒绝</button>";
btns = " <button onclick=\"decideApproval('" + esc(a.id) + "', 'approved')\">记录允许</button>" +
" <button onclick=\"decideApproval('" + esc(a.id) + "', 'denied')\">记录拒绝</button>";
}
return "[" + esc(a.kind) + "|" + esc(a.status) + "] " + esc(a.summary) + receipt + btns;
}).join("<br>");
@@ -465,13 +480,20 @@ async function decideApproval(approvalId, decision) {
var verb = decision === "approved" ? "approve" : "deny";
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/approvals/" + encodeURIComponent(approvalId) + "/" + verb, "POST", {});
$("#approvalMsg").innerHTML = "<span class='ok'>已" + (decision === "approved" ? "允许" : "拒绝") + "</span>";
$("#approvalMsg").innerHTML = "<span class='ok'>已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)</span>";
loadApprovals(currentTask.id);
} catch (e) { $("#approvalMsg").innerHTML = "<span class='err'>决定失败:" + esc(e.message) + "</span>"; }
}
async function loadWorkers() {
try {
var d = await api("/workers", "GET");
// 非全局管理员只拿到聚合数字(服务端 P1 收紧):不展示 worker 清单。
if (d.workers === undefined && d.workers_total !== undefined) {
if (!d.workers_total) { $("#workers").textContent = "无 worker 注册"; return; }
$("#workers").textContent = "在线 " + d.workers_online + " / 离线 " + d.workers_offline +
"(共 " + d.workers_total + ";完整 worker 清单仅全局管理员可见)";
return;
}
var list = d.workers || d || [];
if (!list.length) { $("#workers").textContent = "无 worker 注册"; return; }
$("#workers").innerHTML = list.map(function (w) {
@@ -759,7 +781,7 @@ async function postReview() {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/reviews", "POST", body);
$("#reviewMsg").innerHTML = "<span class='ok'>评审已登记(requested)</span>";
loadReviews(currentTask.id);
} catch (e) { $("#reviewMsg").innerHTML = "<span class='err'>发起评审失败:" + esc(e.message) + "</span>"; }
} catch (e) { $("#reviewMsg").innerHTML = "<span class='err'>登记评审需求失败:" + esc(e.message) + "</span>"; }
}
async function submitReviewFindings(reviewId) {
if (!currentTask) return;
@@ -778,8 +800,19 @@ async function showDetail(taskId) {
document.querySelectorAll("#taskRows tr").forEach(function (tr) {
tr.classList.toggle("selected", tr.getAttribute("data-id") === taskId);
});
// 每个任务状态配一句中文解释(只解释状态语义,不改后端状态机)。
var STATUS_EXPLAIN = {
queued: "排队中,尚未被 worker 认领",
running: "运行中,模型回合进行中",
completed: "模型回合结束、改动在 worktree 里尚未合并",
failed: "模型回合失败,可重试",
interrupted: "已被取消/中断,可重试",
merged: "控制面已标记 merged,不代表 git 主分支已合入",
"merge-failed": "合并落盘失败,可重试"
};
$("#detail").innerHTML =
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b> | " +
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b>(" +
esc(STATUS_EXPLAIN[t.status] || "未知状态") + ") | " +
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
" | 权限 " + esc(t.approval || "默认") +
@@ -800,7 +833,35 @@ async function showDetail(taskId) {
pre.style.display = "block";
$("#detailActions").style.display = "flex";
$("#btnFork").style.display = TRACE_TERMINAL.indexOf(t.status) >= 0 ? "" : "none";
$("#actionMsg").textContent = "";
// 按钮按状态 + 当前用户项目角色禁用并写明原因(前端禁用只是
// 防误点,真正鉴权仍在服务端每次请求时做;角色来自项目列表
// 的 my_role 字段,未知角色时按最严(仅取消可用)处理)。
(function () {
var proj = null;
(projectsCache || []).forEach(function (p) { if (p.id === t.project_id) proj = p; });
var role = proj ? proj.my_role : null;
var isMaintainer = (role === "maintainer" || role === "owner");
var canCreate = (role === "contributor" || isMaintainer);
var terminal = TRACE_TERMINAL.indexOf(t.status) >= 0;
var rules = [
{ btn: "#btnRetry", ok: ["failed", "interrupted", "merge-failed"].indexOf(t.status) >= 0 && isMaintainer,
why: "仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试") },
{ btn: "#btnCancel", ok: !terminal && canCreate,
why: terminal ? "终态任务不可取消" : (canCreate ? "" : "仅项目成员(contributor 及以上)可取消") },
{ btn: "#btnMerge", ok: t.status === "completed" && isMaintainer,
why: "仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并") },
{ btn: "#btnPr", ok: t.status === "merged" && isMaintainer,
why: "仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR") }
];
var reasons = [];
rules.forEach(function (r) {
var b = $(r.btn);
b.disabled = !r.ok;
b.title = r.ok ? "" : r.why;
if (!r.ok && r.why) reasons.push(b.textContent.replace(/(.*$/, "") + ":不可用——" + r.why);
});
$("#actionMsg").textContent = reasons.length ? reasons.join(";") : "";
})();
$("#costBox").innerHTML = "";
$("#forkForm").style.display = "none";
$("#forkMsgSpan").textContent = "";
@@ -1062,6 +1123,12 @@ $("#createTask").onclick = async function () {
try { body.coordinator_parts = JSON.parse(coordRaw); }
catch (e) { $("#taskMsg").textContent = "协调员子任务 JSON 解析失败"; return; }
if (!Array.isArray(body.coordinator_parts)) { $("#taskMsg").textContent = "协调员子任务必须是数组"; return; }
// 协调员与 goal/计划互斥:提交前校验并在表单处写明冲突
// (服务端同样 400,但不要等到服务端才发现)。
if ($("#goalMode").checked || $("#planMode").checked) {
$("#taskMsg").textContent = "协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON";
return;
}
}
var atts = gatherAttachments();
if (atts.length) {