@sema-agent/server 7.10.0 → 7.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/USAGE.md +56 -0
- package/dist/adoption/plan.d.ts +152 -0
- package/dist/adoption/plan.js +513 -0
- package/dist/adoption/runner.d.ts +54 -0
- package/dist/adoption/runner.js +505 -0
- package/dist/adoption/sql.d.ts +76 -0
- package/dist/adoption/sql.js +106 -0
- package/dist/adoption/wire.d.ts +250 -0
- package/dist/adoption/wire.js +153 -0
- package/dist/approval-card.d.ts +24 -0
- package/dist/approval-card.js +32 -0
- package/dist/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/adoption.d.ts +30 -0
- package/dist/boot/adoption.js +57 -0
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/parked-revive-gate.d.ts +56 -7
- package/dist/boot/parked-revive-gate.js +177 -8
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +57 -0
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/boot/runner-deps.d.ts +10 -2
- package/dist/boot/runner-deps.js +12 -1
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +24 -1
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -75
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/adoption.d.ts +26 -0
- package/dist/http/routes/adoption.js +120 -0
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +49 -2
- package/dist/http/routes/rules.d.ts +35 -0
- package/dist/http/routes/rules.js +293 -0
- package/dist/http/routes/shared-memory.d.ts +31 -0
- package/dist/http/routes/shared-memory.js +181 -0
- package/dist/http/routes/trace-usage.js +139 -3
- package/dist/http/server.d.ts +23 -3
- package/dist/http/server.js +123 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +70 -3
- package/dist/observability/fail-open.d.ts +12 -0
- package/dist/observability/fail-open.js +12 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/adoption-log-sql.d.ts +191 -0
- package/dist/plugins/adoption-log-sql.js +273 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
- package/dist/plugins/checkpoint-store-sql.js +11 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +8 -0
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +249 -0
- package/dist/plugins/permission-rule-store-sql.js +828 -0
- package/dist/plugins/pg-pool.js +37 -0
- package/dist/plugins/session-policy-store-sql.d.ts +6 -0
- package/dist/plugins/session-policy-store-sql.js +7 -1
- package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
- package/dist/plugins/shared-memory-store-sql.js +516 -0
- package/dist/plugins/store-backend.d.ts +38 -2
- package/dist/plugins/store-backend.js +69 -6
- package/dist/plugins/tidb-pool.js +47 -0
- package/dist/rules-consent.d.ts +194 -0
- package/dist/rules-consent.js +240 -0
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/shared-memory-scope-authorizer.d.ts +29 -0
- package/dist/shared-memory-scope-authorizer.js +17 -0
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +60 -0
- package/dist/tool-approval.js +223 -25
- package/dist/trace/core-keyset-guard.d.ts +15 -4
- package/dist/trace/project.d.ts +20 -2
- package/dist/trace/project.js +25 -4
- package/package.json +3 -3
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* [2942]/[2943] `governanceForced` 的**判定缝** —— operator 治理层产的 ask 在 server 进程内的标记通道。
|
|
3
3
|
*
|
|
4
|
-
* 🔴 为什么需要一条 out-of-band 通道(侦察结论,亲验装树 core 5.16.x
|
|
5
|
-
* `PermissionResult.decisionReason`(
|
|
4
|
+
* 🔴 为什么需要一条 out-of-band 通道(侦察结论,亲验装树 core dist;5.16.x 首次落笔,5.23.0 复核仍成立):
|
|
5
|
+
* `PermissionResult.decisionReason`(闭集,词表以 core 的 `DecisionReason` 为准 —— 本文**刻意不复述**那
|
|
6
|
+
* 张表:复述出来的词表会随 core 加员静默过期,而这段话的论点与表里有几个词无关)**到不了帧铸点**。
|
|
6
7
|
* core 在 `dist/core/runner/prepare-task.js` 的 ask 铸造点(`resolveAsk({...})`,三处继承臂 + 主臂)只把
|
|
7
8
|
* `toolName / toolCallId / args / preview / message / askSourceIdentity() / riskAxesOf() / requiresRealApproval`
|
|
8
9
|
* 装进 `AskRequest`;`decisionReason` 连同整个 `PermissionResult` 一起留在 core 内部。`AskRequest` 的类型面
|
|
9
10
|
* (`dist/core/tool-policy.d.ts:78`)也确认没有这个键。所以「映射 decisionReason ⇒ governanceForced」这条
|
|
10
11
|
* 缝在**当前 core 上不存在**——它不是没接线,是没有这个字段可读。
|
|
12
|
+
* ([3372] 提货批复核)core 5.23.0 给这个闭集加了 `org_rule` / `org_unavailable` 两个**治理来源**的词
|
|
13
|
+
* (design/182 org 层)。这看着像是本表终于有了上游判据,其实**不是**:①`AskRequest` 的类型面在 5.23.0
|
|
14
|
+
* 上逐字未变,那两个词仍然到不了帧铸点(缝还是不存在);②即便够得着,它们说的是**组织**发布的快照,
|
|
15
|
+
* 与本键的语义(**本部署运维治理层**产的 ask)不是一回事,直接映射会把两种来源混成一个标记。本仓无
|
|
16
|
+
* org 层装配口(`ToolGateInput.orgRules` 无声明点),该臂当前不可达。
|
|
11
17
|
*
|
|
12
18
|
* 另一侧同样不可用:`riskAxes.irreversible` 在 shellGate 上场时对**每一次** shell 调用都为真(core
|
|
13
19
|
* `prepare-task.js:1620-1631` 无条件 `irreversibilityTier.set("Bash"/"Monitor", …)`),与「这只 ask 是谁
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 本文件对 server.ts 的引用一律 `import type`(tsc 擦除,非装载边)。
|
|
16
16
|
*/
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
|
-
import type { TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, Runner } from "@sema-agent/core";
|
|
18
|
+
import type { ApprovalSettledBy, TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, Runner } from "@sema-agent/core";
|
|
19
19
|
import type { FlatServiceDeps, RequestAuth } from "./server.js";
|
|
20
20
|
import type { TaskRequestBody, DecideBinding } from "./wire-types.js";
|
|
21
21
|
import type { IdempotencyCache } from "./idempotency.js";
|
|
@@ -120,8 +120,11 @@ export interface RouteLegs {
|
|
|
120
120
|
prepareSpec(req: IncomingMessage, res: ServerResponse): Promise<PreparedTaskSubmission | null>;
|
|
121
121
|
/** 同步腿的终局记账(计费/配额/指标),tasks 域用。 */
|
|
122
122
|
finalizeTaskResult(result: TaskResult, principal: string | undefined, objective: string, sessionId: string | undefined): void;
|
|
123
|
-
/** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。
|
|
124
|
-
|
|
123
|
+
/** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。
|
|
124
|
+
* `settledBy` = **这次结算的出处**(core `ApprovalSettledBy`),必填:本腿有两个调用方(HTTP
|
|
125
|
+
* `/decide` = 人;内部 D-D SLA deny-sweep = 窗到期),而函数内部分辨不出谁在叫它 —— 必填参数把
|
|
126
|
+
* 「漏报出处」变成编译错,不是一条下游把「没人答」渲染成「有人拒」的静默假出处。 */
|
|
127
|
+
resumeCheckpoint(sessionId: string, decision: "approve" | "deny", reason: string | undefined, settledBy: ApprovalSettledBy, req?: IncomingMessage, answer?: QuestionAnswer, binding?: DecideBinding, onResumeCommitted?: (overrideSessionId?: string) => Promise<void>): Promise<{
|
|
125
128
|
status: number;
|
|
126
129
|
body: object;
|
|
127
130
|
}>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/183 §4.3 —— **收编面**(operator lane):`POST /v1/adoption` 发起、`GET /v1/adoption/:id` 读状态。
|
|
3
|
+
*
|
|
4
|
+
* 形态:一次性的**部署级动作**,不是常驻业务面 —— 所以它 billable=false(零模型工作)、operator-only、
|
|
5
|
+
* 且在没有 SQL 后端的部署上诚实 501(收编重绑的是多租身份轴,local 后端根本没有那个轴)。
|
|
6
|
+
*
|
|
7
|
+
* ── 三条拒的分家(183 §7.3 拒绝可判别)──────────────────────────────────────────────────────────
|
|
8
|
+
* · `adoption.source_already_bound` (409):同一个源已绑到**别的**目的地 = 二次收编/多租转让,
|
|
9
|
+
* 183 D7 明确拒,非本协议射程。与「已收编回执」不同形 —— 后者是 200。
|
|
10
|
+
* · `adoption.destination_conflict` (409):目的地侧已有与源侧重叠的逻辑键。**响应列出冲突表族**,
|
|
11
|
+
* 运维据此知道该去清理哪张表;源/目的地两侧字节零变更。
|
|
12
|
+
* · `adoption.destination_unrepresentable` (409):目的地身份装不进这次收编会**派生**出来的某个键
|
|
13
|
+
* (典型:memory 的 `proj:` 键 = 新 tenant 段 + 原有项目段后缀,合起来越过列宽)。与上一条分家的理由:
|
|
14
|
+
* 运维的动作完全不同 —— 那条要去清理另一个 principal 的行,这条要换一个更短的目的地身份。
|
|
15
|
+
* · `not_found.adoption` (404):operator 拿着一个不存在的 id 来读。
|
|
16
|
+
*
|
|
17
|
+
* 幂等:同参数重跑秒回**同一份** `AdoptionReceipt`(`immutableReport` 逐字节恒同 —— 它在 phase 6
|
|
18
|
+
* 一次落库,此后原样回放)。并发同参 POST 由 `adoption_log.from_principal` 的 UNIQUE 仲裁:恰一行落库,
|
|
19
|
+
* 两条连接拿到同一个 adoptionId、同一份回执。
|
|
20
|
+
*
|
|
21
|
+
* 分层:本模块不值 import `server.ts`(那条边闭合运行时装载环),只 `import type`。
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
24
|
+
import type { RouteCtx } from "../route-ctx.js";
|
|
25
|
+
export declare function handleAdoption(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
26
|
+
//# sourceMappingURL=adoption.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createAdoptionRunner } from "../../adoption/runner.js";
|
|
2
|
+
import { AdoptionRequestSchema } from "../../adoption/wire.js";
|
|
3
|
+
import { checkAdoptionWidths } from "../../adoption/plan.js";
|
|
4
|
+
import { sendJson, sendError } from "../send.js";
|
|
5
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
6
|
+
const ADOPTION_ID_RE = /^\/v1\/adoption\/([^/]+)$/;
|
|
7
|
+
/** 结局 → wire。**拒从不走 200 回执包络**(见顶注三条拒的分家)。
|
|
8
|
+
* 成功码写死 200(不收 `okStatus` 参数):一个**表达式**状态码会让 errorCode 归一门读不出这是成功面,
|
|
9
|
+
* 而且本面本来就只有一个成功码 —— 同参重跑必须与首次同形,重跑创建不了任何东西。 */
|
|
10
|
+
function sendOutcome(res, out) {
|
|
11
|
+
switch (out.kind) {
|
|
12
|
+
case "receipt":
|
|
13
|
+
sendJson(res, 200, { adoptionId: out.adoptionId, ...out.receipt });
|
|
14
|
+
return;
|
|
15
|
+
case "source_already_bound":
|
|
16
|
+
sendError(res, 409, "adoption.source_already_bound", "this source principal is already bound to a different adoption target", {
|
|
17
|
+
adoptionId: out.adoptionId,
|
|
18
|
+
fromPrincipal: out.fromPrincipal,
|
|
19
|
+
boundTo: out.boundTo,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
// 闭集穷举:`AdoptionRejectCode` 加一个成员而这里没加臂 ⇒ **编译红**(拒绝码是 wire 契约,
|
|
23
|
+
// 不许有一个走到默认臂的静默码)。
|
|
24
|
+
case "rejected":
|
|
25
|
+
switch (out.code) {
|
|
26
|
+
case "adoption.destination_conflict":
|
|
27
|
+
sendError(res, 409, "adoption.destination_conflict", "the destination principal already holds rows sharing a logical key with the source", {
|
|
28
|
+
adoptionId: out.adoptionId,
|
|
29
|
+
detail: out.detail,
|
|
30
|
+
conflicts: out.conflicts,
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
case "adoption.destination_unrepresentable":
|
|
34
|
+
sendError(res, 409, "adoption.destination_unrepresentable", "the destination principal does not fit every key this adoption would derive from it", {
|
|
35
|
+
adoptionId: out.adoptionId,
|
|
36
|
+
detail: out.detail,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function handleAdoption(req, res, url, ctx) {
|
|
44
|
+
const miss = { fell: false };
|
|
45
|
+
await handleAdoptionBody(req, res, url, ctx, miss);
|
|
46
|
+
return !miss.fell;
|
|
47
|
+
}
|
|
48
|
+
async function handleAdoptionBody(req, res, url, ctx, miss) {
|
|
49
|
+
const { deps } = ctx;
|
|
50
|
+
const { readJson } = ctx.helpers;
|
|
51
|
+
const isPost = req.method === "POST" && url === "/v1/adoption";
|
|
52
|
+
const idMatch = req.method === "GET" ? ADOPTION_ID_RE.exec(url) : null;
|
|
53
|
+
if (!isPost && !idMatch) {
|
|
54
|
+
miss.fell = true;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// 门序 = operator 面的既有口径:身份(401)→ 授权(403)→ 能力(501)→ 验型(400)。
|
|
58
|
+
// 授权在能力之前:一个够不着任何东西的调用方不该从「有没有 SQL 后端」上读出部署形态。
|
|
59
|
+
const principal = gatedPrincipal(req, deps.config); // direct-door safe: verified identity, never the spoofable header
|
|
60
|
+
if (deps.config.requirePrincipal && !principal) {
|
|
61
|
+
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
|
|
65
|
+
sendError(res, 403, "auth.operator_only", "adoption is an operator-only deployment action");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const store = deps.backend?.adoptionLog?.();
|
|
69
|
+
if (!store) {
|
|
70
|
+
sendError(res, 501, "capability.adoption_store_required", "adoption requires a SQL store backend (DB_BACKEND=mysql|pg)");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const runner = createAdoptionRunner({ store, dialect: deps.backend?.kind === "pg" ? "pg" : "tidb", now: Date.now, logger: deps.logger });
|
|
74
|
+
if (idMatch) {
|
|
75
|
+
const id = ctx.helpers.safeDecode(idMatch[1]);
|
|
76
|
+
if (id === null) {
|
|
77
|
+
// 复用既有的 `request.id_invalid`(sessions/attachments 同族),不为一条新路由铸第二个同义码
|
|
78
|
+
// ——附录 A 的判据②:只有消费端真会分支时才铸新码。
|
|
79
|
+
sendError(res, 400, "request.id_invalid", "invalid adoption id");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const out = await runner.get(id);
|
|
83
|
+
if (!out) {
|
|
84
|
+
sendError(res, 404, "not_found.adoption", "adoption not found");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
sendOutcome(res, out);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await readJson(req);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const parsed = AdoptionRequestSchema.safeParse(raw);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
sendError(res, 400, "request.body_shape", "invalid adoption body — expected {fromPrincipal, toPrincipal} as non-empty strings");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const { fromPrincipal, toPrincipal } = parsed.data;
|
|
104
|
+
// 🔴 列宽校验在**铸行之前**(codex R1-F3):合法长度的 principal 也可能装不进最窄的那根被写列,
|
|
105
|
+
// 而那种失败若发生在腿的中途,phase 会永远停在 INTENT —— 每次重发和每次 boot 扫描都再撞一次同样的墙。
|
|
106
|
+
const tooWide = checkAdoptionWidths(fromPrincipal, toPrincipal);
|
|
107
|
+
if (tooWide !== undefined) {
|
|
108
|
+
sendError(res, 400, "request.field_invalid", "the adoption principals do not fit every identity column and derived key this adoption must write", { detail: tooWide });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (fromPrincipal === toPrincipal) {
|
|
112
|
+
// 183 §8 的「nothing to adopt」分支:同名不是一次无操作的成功,它是一个提错了的请求
|
|
113
|
+
// ——铸一行 adoption_log 会永久占掉那个 from 的 UNIQUE 名额(D7 之后再也收编不了),所以拒在入库之前。
|
|
114
|
+
sendError(res, 400, "request.field_invalid", "fromPrincipal and toPrincipal are the same — nothing to adopt");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const out = await runner.start(fromPrincipal, toPrincipal);
|
|
118
|
+
sendOutcome(res, out);
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=adoption.js.map
|
|
@@ -637,7 +637,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
637
637
|
}
|
|
638
638
|
}
|
|
639
639
|
: undefined;
|
|
640
|
-
|
|
640
|
+
// #204 件6①:出处 = `"human"` —— 这条腿是运维**亲自**在 HTTP 上给的终局(approve/deny 两向都是)。
|
|
641
|
+
const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit);
|
|
641
642
|
if (remember && deps.approvalExemptionStore) {
|
|
642
643
|
sendJson(res, out.status, { ...out.body, rememberApplied });
|
|
643
644
|
return;
|
|
@@ -81,6 +81,40 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
81
81
|
// the old caps honestly hides the affordances (the adaptation was pre-announced).
|
|
82
82
|
memory: false,
|
|
83
83
|
memoryWrite: false,
|
|
84
|
+
// design/177 —— org 共享记忆库只读面。判据**逐字**等于 server.ts 里那个域的挂载合取式(供给面 ∧
|
|
85
|
+
// org 折叠面),所以"说 yes ⟺ 面真能用"是结构性的:少一个,域不挂载、这里也翻假,消费方看到的
|
|
86
|
+
// 与它真能打通的永远一致。折叠面缺席时刻意不报 true —— 一个没有成员性判据的共享读面比没有更糟。
|
|
87
|
+
sharedMemory: Boolean(deps.sharedMemoryStore && deps.orgMemoryDirectory),
|
|
88
|
+
// design/183 form b:收编面(operator lane)。谓词 = **两项合取**,逐项对着 handleAdoption 的
|
|
89
|
+
// 拒绝臂写:①`adoptionLog` 工厂在场(local backend 刻意不实现——单用户数据根没有多租身份轴可
|
|
90
|
+
// 重绑)⇒ 否则 501;②operator 名单**非空** ⇒ 否则 `explicitOperatorOk` 对任何身份都返回 false,
|
|
91
|
+
// 那条路由**恒 403**。
|
|
92
|
+
// 🔴 第二项是 [3397]-4(黑板 cli 审计)逮到的真缺口:旧谓词只查 ① ⇒ 「装了 SQL 后端但没配
|
|
93
|
+
// OPERATOR_PRINCIPALS」的部署位报 true、路由恒 403 —— 本仓「says yes ⟺ route works」被实测证否。
|
|
94
|
+
// 空名单在这条轴上**刻意**不走 `isOperator` 的「人人都是 operator」旧义(那是一道世界可写的门,
|
|
95
|
+
// 见 principal-gate.ts 的 `explicitOperatorOk` 头注),所以补的是能力位、不是放宽授权。
|
|
96
|
+
adoption: Boolean(deps.backend?.adoptionLog) && deps.config.operatorPrincipals.length > 0,
|
|
97
|
+
// [3397]-5:`GET /v1/outcomes`(design/73 §7.2 机械信号只读聚合)。此前**有 501 拒而无能力位** ——
|
|
98
|
+
// 消费端只能 trial-by-501,与本仓「gate-don't-trial-by-501」的姿势相反。谓词逐项对着该路由的
|
|
99
|
+
// 拒绝臂:①可查询的账面在场(`outcomeSink.summary`;File sink 只写 JSONL,没有查询面 ⇒ 501);
|
|
100
|
+
// ②多租户下 operator 名单非空(单用户形无此门 —— 唯一的用户就是 operator,路由那侧的
|
|
101
|
+
// `if (deps.config.requirePrincipal)` 逐字如此)。②与 adoption 是**同一族**缺口,同批一起补。
|
|
102
|
+
outcomeLedger: Boolean(deps.outcomeSink?.summary) && (deps.config.requirePrincipal !== true || deps.config.operatorPrincipals.length > 0),
|
|
103
|
+
// #154 车二 + #203 §2:规则车道(cc-import 两口 + 撤销面两口 + respond 的 persistRule 兑付口)。
|
|
104
|
+
// 谓词与 `/v1/rules/*` 全族的 501 同源:ruleConsent lane 在场 ⟺ 总开关未被显式关 ∧ 规则店 wired
|
|
105
|
+
// (SQL 双方言,或 #203 起 local 的 File 三面束)。说 yes ⟺ **四口都活**,不是「有一口活」。
|
|
106
|
+
permissionRules: Boolean(deps.ruleConsent),
|
|
107
|
+
// #205 SDK 车上游请托二连(2026-08-10):
|
|
108
|
+
// · `permissionRulesRevoke` —— 撤销面两口(GET/DELETE /v1/rules)**存在性**的独立信号。谓词与
|
|
109
|
+
// `permissionRules` 同源,但**在场性**本身就是版本信号:≤7.11.0 的 worker `permissionRules` 已为
|
|
110
|
+
// true 而这两口 404(位早于路由),消费端无从区分「面没开」与「版本太老」——本位缺席 = 老版本,
|
|
111
|
+
// 探 404 即可;在场 = 路由已铸。不折进 `permissionRules`:那个位的成文语义(「四口都活」)在
|
|
112
|
+
// ≤7.11.0 的已发布包上已经是谎,只能加位不能改位。
|
|
113
|
+
permissionRulesRevoke: Boolean(deps.ruleConsent),
|
|
114
|
+
// · `oneShot` —— server 消费 `TaskRequest.oneShot`(缺席不写透传 + 非 boolean 400)。旧 server 对
|
|
115
|
+
// 未知键静默容忍 ⇒ 调用方声明了一次性形却拿不到 core 的 block-wait 指引而不自知;本位在场 = 键
|
|
116
|
+
// 真被消费。恒 true(消费不依赖任何可选设施)。
|
|
117
|
+
oneShot: true,
|
|
84
118
|
// CLI surfaces: /v1/policy is always served (config surface); /v1/usage returns real
|
|
85
119
|
// cumulative spend only when a per-principal quota is configured (else enabled:false).
|
|
86
120
|
policy: true,
|
|
@@ -127,14 +161,27 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
127
161
|
// routes (same gate as steer — the verbs share the registry + run store; retain-off runs 409 resume.retain_off).
|
|
128
162
|
subagentResume: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
|
|
129
163
|
// TaskRequest.settings (client per-request SemaSettings stamp) → projected into the engine TIGHTEN-ONLY
|
|
130
|
-
// (deny-wins, after the deployment + operator baseline). v1 wires permissions + permissionMode
|
|
131
|
-
//
|
|
164
|
+
// (deny-wins, after the deployment + operator baseline). v1 wires permissions + permissionMode + model +
|
|
165
|
+
// outputStyle.
|
|
166
|
+
// 🔴 保鲜(design/201 施工期自查):旧文「`bypassPermissions` is NEVER honored from settings」是
|
|
167
|
+
// **pre-[816] 拍照,已作废** —— `parseTaskSettings` 自 [816]/[820] 起白名单收**五**模式,bundle 形的
|
|
168
|
+
// bypass 与顶层键同权(两者的优先序由 `effectivePermissionMode` 单点裁定)。语义不是「开门」而是
|
|
169
|
+
// 「不加模式派生的那道门」:部署/运维基线在此之外合成,settings 掀不掉(tightenTaskSpec deny-wins)。
|
|
170
|
+
// 留着那句过期的否定句比留个欠账更伤:消费方会据此以为 bundle 形 bypass 无效而改用别的写法。
|
|
132
171
|
// `env`([2400] X-8):与真消费点同谓词——resolve-spec 的 setSessionShellEnv 只在 cwdHonored
|
|
133
172
|
// (单用户 host lane)注入 shell env;其余 lane 收到即警告忽略。此前硬编码 false 是「TaskSpec 无
|
|
134
173
|
// per-task env 注入」时代的陈旧断言(R-survey shellEnv seam 落地后失真=说没有但其实有,活能力被藏)。
|
|
135
174
|
// `hooks`(hook-runner 阶段一):TRUE 当本部署会真跑 hook 命令 = 单用户闸(hook 命令跑在
|
|
136
175
|
// worker host,class ② 能力授予;resolveSpec 的 taskHooks 装配同一谓词)。多租户 = false(收到警告忽略)。
|
|
137
176
|
taskSettings: { permissions: true, permissionMode: true, model: true, outputStyle: true, env: cwdHonored(deps.config), hooks: deps.config.requirePrincipal !== true },
|
|
177
|
+
// design/201 §3 前提②:显式 `permissionMode` 会被翻译成 `spec.shellGate` 档
|
|
178
|
+
// (bypassPermissions ⇒ off,auto/default/acceptEdits/plan ⇒ classify;表态缺席 ⇒ 不写键)。
|
|
179
|
+
// **恒 true 且与翻译表同一个 commit** —— 这一位不是"配置在不在",而是"这份二进制里有没有那张表",
|
|
180
|
+
// 所以 says yes ⟺ 翻译真在,是结构性的(某个 deps 缺席不会让它变假)。
|
|
181
|
+
// 消费半场:壳(cli #223)据此撤掉它对 `MANUAL_MODE_SHELL_GATE` 的无条件注入。判据必须是
|
|
182
|
+
// **所连 server 广告本位**,不是壳自身版本 —— 新壳连老 server / 回滚到旧二进制的副本 / 混版 fleet
|
|
183
|
+
// 三形下,壳看见本位缺席就继续注 env,于是"翻译还没到货但壳已经撤注"那个放宽窗结构上不存在。
|
|
184
|
+
modeShellGateTranslation: true,
|
|
138
185
|
// [1476] R1 / [1478] R2: TaskRequest.appendSystemPrompt is accepted top-level → TaskSpec.appendSystemPrompt
|
|
139
186
|
// (first-class lane for the shell's product-knowledge block). A shell probes THIS flag: false/absent ⇒ fall
|
|
140
187
|
// back to the settings.outputStyle ride-along (older servers fold that into the same spec field).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #154 车二 + #203 §2 —— **规则车道的 wire 面**(design/179 §7 的导入半场 + design/203 的撤销半场)。
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/rules/cc-import/prepare → 预览 + 一张**一次性、principal 绑定、有期、载荷绑定**的票
|
|
5
|
+
* POST /v1/rules/cc-import/redeem → 原子消费票 → confirm → 批量兑付进店
|
|
6
|
+
* GET /v1/rules → 列出自己名下**活着**的规则(keyset 分页,游标绑 rev)
|
|
7
|
+
* DELETE /v1/rules → 按 (rule, scope) 内容撤销,**恒经** core `removePersistedRule`
|
|
8
|
+
*
|
|
9
|
+
* **lane = principal**(不是 operator):用户导的是**他自己**的规则,一条规则的语义就是「这个人自己
|
|
10
|
+
* 说过 yes」。operator 在**扩权**这条路上没有位置——他既不该替租户扩权,也不该被要求代人点确认。
|
|
11
|
+
* 因此**导入两口**只认已验明的 principal,连 `explicitOperatorOk` 的旁路都不给:一个 operator 想给
|
|
12
|
+
* 自己导规则,用他自己的 principal 走同一条路即可(那时他就是租户)。
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **撤销面两口多一条 operator 越权域,而这不是上面那句话的例外,是它的对偶**(design/203 §2 设计题
|
|
15
|
+
* 一裁定):`?principal=` / body 里的 `principal` 让 operator 读**或收回**任一租户的规则。方向决定授权:
|
|
16
|
+
* 收回是**收紧**(core 的 `removePersistedRule` 头注逐字:「a host may narrow on a user's behalf, it may
|
|
17
|
+
* not widen」),而多租户管理员能回收权限正是撤销面的本义。读那一半同样只开给 operator ——
|
|
18
|
+
* 规则文本里含命令样式,跨 principal 读是信息面泄露,所以**不开第三方 principal 相互读**。
|
|
19
|
+
* 判据用 `explicitOperatorOk`(空名单 = 全拒),**绝不**从 service token 推断身份:一个共享的部署凭证
|
|
20
|
+
* 不是一个人。
|
|
21
|
+
*
|
|
22
|
+
* **billable = false**:四口都不烧模型(读 settings 文本 / 读写规则行),不进 `isBillableSubmitPath`。
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **四类拒绝在 wire 上同形 404**(信任边界 ①):unknown ticket / 别人的 ticket / 过期 / 已用过 ——
|
|
25
|
+
* 四个成因返回不同的码就是一台存在性 oracle(拿别人的票撞库能探到「它在别处存在」)。分类只进服务端
|
|
26
|
+
* 日志,不上 wire。同族先例 = run/trace 属主门与 `POST /v1/tasks/:id/asks/:askId/decision` 的 404 姿势。
|
|
27
|
+
*/
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
29
|
+
import type { RouteCtx } from "../route-ctx.js";
|
|
30
|
+
export declare const RULES_CC_IMPORT_PREPARE_PATH = "/v1/rules/cc-import/prepare";
|
|
31
|
+
export declare const RULES_CC_IMPORT_REDEEM_PATH = "/v1/rules/cc-import/redeem";
|
|
32
|
+
/** 撤销面两口共用的字面路径(GET 列举 / DELETE 撤销)。 */
|
|
33
|
+
export declare const RULES_PATH = "/v1/rules";
|
|
34
|
+
export declare function handleRules(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
35
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sendJson, sendError } from "../send.js";
|
|
3
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
4
|
+
import { createLogger } from "../../observability/logger.js";
|
|
5
|
+
// 等待秒数的单一真源在车道:它同时被「放回认领时判这张票还撑不撑得过这段窗」用到(见那侧头注)。
|
|
6
|
+
// scope 的 wire 形也在车道(列举列 / 查询参数 / 删除体 / 游标四处同一份表示,见那侧头注)。
|
|
7
|
+
import { RULE_IMPORT_RETRY_AFTER_SEC, parseRuleScope, serializeRuleScope } from "../../rules-consent.js";
|
|
8
|
+
const logger = createLogger();
|
|
9
|
+
export const RULES_CC_IMPORT_PREPARE_PATH = "/v1/rules/cc-import/prepare";
|
|
10
|
+
export const RULES_CC_IMPORT_REDEEM_PATH = "/v1/rules/cc-import/redeem";
|
|
11
|
+
/** 撤销面两口共用的字面路径(GET 列举 / DELETE 撤销)。 */
|
|
12
|
+
export const RULES_PATH = "/v1/rules";
|
|
13
|
+
/** 一页的上下限。50 = 一屏治理列表;200 = 一次响应的上界(一条规则行带 `adds[]`,不是一个标量)。
|
|
14
|
+
* 与 `GET /v1/sessions` 的 keyset 姿势同族(sessions-list.ts:137 的 `Math.min(100, …)`),数值按本面
|
|
15
|
+
* 的行宽另定 —— 抄一个数比抄一条理由更容易漂。 */
|
|
16
|
+
const RULES_PAGE_DEFAULT = 50;
|
|
17
|
+
const RULES_PAGE_MAX = 200;
|
|
18
|
+
/** 一层 settings 的上限。一份 CC settings 是人手写的 JSON,不是数据集;16 KiB 宽到装得下几百条 allow,
|
|
19
|
+
* 窄到一次请求不能拿它当上传通道。层数同理(core v1 只读三层,给 3 就够,多给等于给一个放大面)。 */
|
|
20
|
+
const MAX_LAYER_CONTENT_CHARS = 16_384;
|
|
21
|
+
const MAX_LAYERS = 3;
|
|
22
|
+
/** 🔴 边界必 schema(宪法 [2704])。`.strict()`:多给一个键 = 400,不是静默忽略 —— 一个把 `content`
|
|
23
|
+
* 拼错成 `contents` 的客户端应该当场知道,而不是拿到一份「零候选」的预览去纳闷。 */
|
|
24
|
+
const CcImportPrepareBodySchema = z
|
|
25
|
+
.object({
|
|
26
|
+
layers: z
|
|
27
|
+
.array(z
|
|
28
|
+
.object({
|
|
29
|
+
layer: z.enum(["userSettings", "projectSettings", "localSettings"]),
|
|
30
|
+
path: z.string().min(1).max(1024),
|
|
31
|
+
/** 项目层规则的作用域根。**用户自己的**目录标签(它只影响这条规则将来在哪些 cwd 下生效),
|
|
32
|
+
* 故不做**真实性**校验——server 上没有那棵树可对照,假装校验过才是谎。
|
|
33
|
+
* 🔴 但**非空**是硬的(codex 交叉复审 round5 [high] 一,验真后修):`root: ""` 会被 core 直接
|
|
34
|
+
* 变成 `{kind:"project", root:""}`,而空前缀在 `pathWithinRoot` 下**包含每一个 cwd** —— 一条
|
|
35
|
+
* 项目内规则就此悄悄升成全局规则;更糟的是本仓的读面对空 root 是整桶拒,于是一次「成功」的
|
|
36
|
+
* 导入会让这位租户**之后每一次**规则读写都失败。 */
|
|
37
|
+
root: z.string().min(1).max(1024),
|
|
38
|
+
content: z.string().max(MAX_LAYER_CONTENT_CHARS),
|
|
39
|
+
})
|
|
40
|
+
.strict())
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(MAX_LAYERS),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
const CcImportRedeemBodySchema = z.object({ ticket: z.string().min(1).max(190) }).strict();
|
|
46
|
+
/** `DELETE /v1/rules` 的体。`.strict()` 同族理由:把 `scope` 拼错成 `scopes` 的客户端应该当场知道,
|
|
47
|
+
* 而不是拿到一个「删掉了 global 那条」的意外结果。`principal` = operator 越权域(缺席 = 删自己的)。
|
|
48
|
+
* 长度上限与 principal 入口上限同源(`PRINCIPAL_MAX_LENGTH = 190`,security.ts);`rule` 的 1024 与
|
|
49
|
+
* cc-import 的 `root` 同族(一条规则文本是人写的一行,不是载荷)。 */
|
|
50
|
+
const RuleRevokeBodySchema = z
|
|
51
|
+
.object({
|
|
52
|
+
rule: z.string().min(1).max(1024),
|
|
53
|
+
scope: z.string().min(1).max(1088), // "project:" + root(1024)+ 余量
|
|
54
|
+
principal: z.string().min(1).max(190).optional(),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
const RulesCursorSchema = z
|
|
58
|
+
.object({ rev: z.number().int().nonnegative().safe(), principal: z.string(), scope: z.string(), after: z.string() })
|
|
59
|
+
.strict();
|
|
60
|
+
function encodeRulesCursor(c) {
|
|
61
|
+
return Buffer.from(JSON.stringify(c)).toString("base64url");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 读不出形 ⇒ `{ok:false, why}`,由调用方拒(400)。两条纪律:
|
|
65
|
+
* · **刻意不**「读不出就当第一页」(sessions-list 的旧姿势):那条腿的游标只影响顺序,这条腿的游标
|
|
66
|
+
* 一旦被静默丢弃,客户端会以为自己列完了整份清单。
|
|
67
|
+
* · 失败**带着原因出来**而不是塌成一个 `undefined`:调用点把 `why` 写进服务端日志。一个只回默认值的
|
|
68
|
+
* catch 是本仓在册的静默降级形(#191 门② SHAPE C)——这里不需要那种便利,原因本来就有用。
|
|
69
|
+
*/
|
|
70
|
+
function decodeRulesCursor(raw) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = RulesCursorSchema.safeParse(JSON.parse(Buffer.from(raw, "base64url").toString()));
|
|
73
|
+
return parsed.success ? { ok: true, cursor: parsed.data } : { ok: false, why: parsed.error.message };
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return { ok: false, why: err instanceof Error ? err.message : String(err) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** 排序键 —— 与 `RuleConsentLane.listRules` 的 (scope, rule) 二元排序**等价**(两处若各写一份必然漂,
|
|
80
|
+
* 而漂的表现是分页静默漏行)。分隔符必须**小于**任何可能出现的字符,否则串比较与元组比较会在
|
|
81
|
+
* 「一个 scope 是另一个的前缀」处分岔(`project:/a` vs `project:/ab`)。用空格是错的 ——
|
|
82
|
+
* `Bash(echo hi)` 里就有一个;\u0000 是码点最小的字符,而且规则文本与路径里都不可能出现它。 */
|
|
83
|
+
const rowKey = (r) => `${r.scope}\u0000${r.rule}`;
|
|
84
|
+
export async function handleRules(req, res, url, ctx) {
|
|
85
|
+
const miss = { fell: false };
|
|
86
|
+
await handleRulesBody(req, res, url, ctx, miss);
|
|
87
|
+
return !miss.fell;
|
|
88
|
+
}
|
|
89
|
+
async function handleRulesBody(req, res, url, ctx, miss) {
|
|
90
|
+
const { deps } = ctx;
|
|
91
|
+
const { readJson, rateLimited } = ctx.helpers;
|
|
92
|
+
const isPrepare = req.method === "POST" && url === RULES_CC_IMPORT_PREPARE_PATH;
|
|
93
|
+
const isRedeem = req.method === "POST" && url === RULES_CC_IMPORT_REDEEM_PATH;
|
|
94
|
+
// 🔴 判路写**字面量**而不是引 `RULES_PATH`(与 server.ts 的 `ROUTE_LABEL_LITERALS` 那条注同族理由):
|
|
95
|
+
// 路由名册门与文案接线门的枚举器扫的是 `url === "…"` 的**源码文本**,引常量会让这两口对两道门隐形
|
|
96
|
+
// ——那不是「过了门」,是绕过门。常量仍导出给测试/SDK 侧当单一真源用。
|
|
97
|
+
const isList = req.method === "GET" && url === "/v1/rules";
|
|
98
|
+
const isRevoke = req.method === "DELETE" && url === "/v1/rules";
|
|
99
|
+
if (!isPrepare && !isRedeem && !isList && !isRevoke) {
|
|
100
|
+
miss.fell = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// 🔴 `prepare` 每次调用**都会**落一条 pending 审批记录 + 一张票 —— 它是本域唯一一条「一次请求 = 一行」
|
|
104
|
+
// 的写面,没有限流就是一条行洪口。与 `POST /v1/tasks/:id/asks/:askId/decision` 同姿势(限流在最前)。
|
|
105
|
+
// 撤销面两口同罩:`DELETE` 是写(每次铸一个 dot + 一条墓碑),`GET` 是整桶读 —— 两者都不该无限打。
|
|
106
|
+
if (rateLimited(req, res))
|
|
107
|
+
return;
|
|
108
|
+
// 鉴权**先于**可用性(501-before-auth 先例的反面同理):这四口没有可被探测的部署事实,但
|
|
109
|
+
// principal 是本域的全部授权基础,先立住它再谈别的。
|
|
110
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
111
|
+
if (principal === undefined) {
|
|
112
|
+
sendError(res, 401, "auth.unauthorized", "unauthorized");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const lane = deps.ruleConsent;
|
|
116
|
+
if (lane === undefined) {
|
|
117
|
+
sendError(res, 501, "capability.rule_store_required", "this deployment has no permission-rule store wired");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// ── 撤销面两口(#203 §2)──────────────────────────────────────────────────────────────────────
|
|
121
|
+
// 放在读体之前:`GET` 没有体,`readJson` 对它是一次白等。
|
|
122
|
+
if (isList) {
|
|
123
|
+
await handleRuleList(req, res, principal, lane, deps.config.operatorPrincipals);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let rawBody;
|
|
127
|
+
try {
|
|
128
|
+
rawBody = await readJson(req);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
sendError(res, 400, "request.body_shape", "body must be valid JSON");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (isRevoke) {
|
|
135
|
+
await handleRuleRevoke(res, rawBody, principal, lane, deps.config.operatorPrincipals);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (isPrepare) {
|
|
139
|
+
const parsed = CcImportPrepareBodySchema.safeParse(rawBody);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
sendError(res, 400, "request.body_shape", "body must be {layers:[{layer,path,root,content}]}");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const prepared = await lane.prepareImport(principal, parsed.data.layers);
|
|
145
|
+
if (!prepared.ok) {
|
|
146
|
+
// 413(不是 400):族语义就是「体/条目超上限」,与附件/图片同码同状态(附录 A 已成文)。
|
|
147
|
+
// 帽的位置与理由(为什么单层字节帽不够、为什么帽在**铸票之前**)见 `rules-consent.ts` 的
|
|
148
|
+
// `MAX_IMPORT_CANDIDATES`。
|
|
149
|
+
sendError(res, 413, "request.payload_too_large", "too many allow entries across the submitted settings layers");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
sendJson(res, 200, { preview: prepared.preview, ticket: prepared.ticket, expiresAtMs: prepared.expiresAtMs });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const parsed = CcImportRedeemBodySchema.safeParse(rawBody);
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
sendError(res, 400, "request.body_shape", "body must be {ticket}");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const redeemed = await lane.redeemImport(principal, parsed.data.ticket);
|
|
161
|
+
if (!redeemed.ok) {
|
|
162
|
+
logger.info("rule import ticket refused", { reason: redeemed.reason, detail: redeemed.detail, retryable: redeemed.retryable === true });
|
|
163
|
+
// 🔴 codex 交叉复审 round2 [medium](验真后修):**可重试**那一支不能折进 404。
|
|
164
|
+
// 为什么这不破坏零存在性 oracle:走到这一支的调用方**已经赢下了一次 principal 绑定的认领** ——
|
|
165
|
+
// 也就是说他已经证明了这张票是他自己的、没过期、没用过。对他说「稍后再试」泄不出任何**别人**的
|
|
166
|
+
// 存在性(伪造 / 别人的 / 过期 / 已用过 四类仍然同形 404,一个字都没变)。反过来把它折成 404 才是
|
|
167
|
+
// 真损失:round1 修好的「认领已放回、这张票还能用」在 wire 上被抹掉,客户端按常规 404 处置一丢了之。
|
|
168
|
+
if (redeemed.retryable === true) {
|
|
169
|
+
res.setHeader("retry-after", String(RULE_IMPORT_RETRY_AFTER_SEC)); // 体里有等待提示 ⇒ 标准头同值(同 503 族先例)
|
|
170
|
+
sendError(res, 503, "state.rule_import_retry", "the rule import could not be completed right now; the ticket is still usable — retry", { retryAfterSec: RULE_IMPORT_RETRY_AFTER_SEC });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// 同形 404(顶注):四类成因**不上 wire**,只进服务端日志。
|
|
174
|
+
sendError(res, 404, "not_found.rule_ticket", "no such import ticket");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
sendJson(res, 200, { result: redeemed.result });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 解析**目标 principal**(自有域 vs operator 越权域)。
|
|
181
|
+
*
|
|
182
|
+
* 🔴 判据链是硬的:①没给 = 自己(最常见的路,零授权提问);②给了但等于自己 = 还是自己(一个把
|
|
183
|
+
* principal 显式写出来的客户端不该因此被要求当 operator);③给了别人 = **必须** `explicitOperatorOk`。
|
|
184
|
+
* 第三条用的是 `explicitOperatorOk` 而不是 `isOperator`:后者在 `OPERATOR_PRINCIPALS` 为空时「人人
|
|
185
|
+
* 都是 operator」,那在跨租户读规则这条轴上是一道世界可写的门(principal-gate.ts 的头注逐字)。
|
|
186
|
+
* **绝不**从 service token 推断:一个共享的部署凭证不是一个人,而这条口的授权对象是人。
|
|
187
|
+
*/
|
|
188
|
+
function resolveTargetPrincipal(requested, caller, operatorPrincipals) {
|
|
189
|
+
if (requested === undefined || requested === caller)
|
|
190
|
+
return { ok: true, principal: caller };
|
|
191
|
+
if (!explicitOperatorOk(caller, operatorPrincipals))
|
|
192
|
+
return { ok: false };
|
|
193
|
+
return { ok: true, principal: requested };
|
|
194
|
+
}
|
|
195
|
+
/** `GET /v1/rules` —— 列出一位 principal 名下**活着**的规则。 */
|
|
196
|
+
async function handleRuleList(req, res, caller, lane, operatorPrincipals) {
|
|
197
|
+
// `url` 在派发前已被剥掉 query,查询参数只能从 `req.url` 重新取(与 `/v1/outcomes` 同姿势)。
|
|
198
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
199
|
+
const target = resolveTargetPrincipal(q.get("principal") ?? undefined, caller, operatorPrincipals);
|
|
200
|
+
if (!target.ok) {
|
|
201
|
+
sendError(res, 403, "auth.operator_only", "reading another principal's rules is an operator-only action");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// limit:非数/越界一律夹取(与 sessions-list 同姿势——一个分页参数写错不该让治理面整个打不开)。
|
|
205
|
+
const limit = Math.min(RULES_PAGE_MAX, Math.max(1, Number(q.get("limit") ?? RULES_PAGE_DEFAULT) || RULES_PAGE_DEFAULT));
|
|
206
|
+
// scope 过滤:**给了就必须读得出形**。读不出不当「没过滤」——那会把一次「只看这个项目」的请求
|
|
207
|
+
// 悄悄变成一次全量列举,而客户端会照单全收当成该项目的清单。
|
|
208
|
+
const scopeRaw = q.get("scope");
|
|
209
|
+
const scopeFilter = scopeRaw === null ? undefined : parseRuleScope(scopeRaw);
|
|
210
|
+
if (scopeRaw !== null && scopeFilter === undefined) {
|
|
211
|
+
sendError(res, 400, "request.query_invalid", "scope must be 'global' or 'project:<root>' with a non-empty root");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const cursorRaw = q.get("cursor");
|
|
215
|
+
const decoded = cursorRaw === null ? undefined : decodeRulesCursor(cursorRaw);
|
|
216
|
+
if (decoded !== undefined && !decoded.ok) {
|
|
217
|
+
logger.info("rules cursor unreadable", { why: decoded.why });
|
|
218
|
+
sendError(res, 400, "request.query_invalid", "the rules cursor is unreadable — restart the listing without one");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const cursor = decoded?.ok === true ? decoded.cursor : undefined;
|
|
222
|
+
// 过滤键用**规范形**(`serializeRuleScope` 的产物),不是客户端交上来的原串:行上的 scope 是规范的,
|
|
223
|
+
// 拿一个等价但拼法不同的原串去比会静默过滤成空表。空串 = 未过滤(游标里也用这一个表示)。
|
|
224
|
+
const filterKey = scopeFilter === undefined ? "" : serializeRuleScope(scopeFilter);
|
|
225
|
+
const listing = await lane.listRules(target.principal);
|
|
226
|
+
if (cursor !== undefined && (cursor.rev !== listing.rev || cursor.principal !== target.principal || cursor.scope !== filterKey)) {
|
|
227
|
+
// 游标绑 (rev, principal, scope)(见 {@link RulesCursor} 头注)。**拒**而不是从头给:静默重置会让
|
|
228
|
+
// 客户端把第二页当成「接着上一页」,治理面上于是出现一份既漏行又重行的清单,而没有任何人知道。
|
|
229
|
+
// 三项合并成一句文案是刻意的:对客户端来说处置完全相同(丢掉游标从头列),而把「你换了 principal」
|
|
230
|
+
// 单独成句会让一个 operator 从别人桶的 rev 上读出信息。
|
|
231
|
+
sendError(res, 400, "request.query_invalid", "the rules cursor was minted at a different revision — restart the listing", { rev: listing.rev });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const matching = listing.rows.filter((r) => (filterKey === "" || r.scope === filterKey) && (cursor === undefined || rowKey(r) > cursor.after));
|
|
235
|
+
const page = matching.slice(0, limit);
|
|
236
|
+
const last = page[page.length - 1];
|
|
237
|
+
const nextCursor = matching.length > limit && last !== undefined
|
|
238
|
+
? encodeRulesCursor({ rev: listing.rev, principal: target.principal, scope: filterKey, after: rowKey(last) })
|
|
239
|
+
: undefined;
|
|
240
|
+
sendJson(res, 200, { rules: page, rev: listing.rev, ...(nextCursor !== undefined ? { nextCursor } : {}) });
|
|
241
|
+
}
|
|
242
|
+
/** `DELETE /v1/rules` —— 按内容(rule, scope)撤销。 */
|
|
243
|
+
async function handleRuleRevoke(res, rawBody, caller, lane, operatorPrincipals) {
|
|
244
|
+
const parsed = RuleRevokeBodySchema.safeParse(rawBody);
|
|
245
|
+
if (!parsed.success) {
|
|
246
|
+
sendError(res, 400, "request.body_shape", "body must be {rule, scope} with an optional principal");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const scope = parseRuleScope(parsed.data.scope);
|
|
250
|
+
if (scope === undefined) {
|
|
251
|
+
sendError(res, 400, "request.body_shape", "scope must be 'global' or 'project:<root>' with a non-empty root");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const target = resolveTargetPrincipal(parsed.data.principal, caller, operatorPrincipals);
|
|
255
|
+
if (!target.ok) {
|
|
256
|
+
// 与读那一半**同码同姿势**:方向(收紧)决定了这条越权域存在,身份判据不因方向而放松。
|
|
257
|
+
sendError(res, 403, "auth.operator_only", "revoking another principal's rules is an operator-only action");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const outcome = await lane.removeRule({ principal: target.principal, rule: parsed.data.rule, scope });
|
|
261
|
+
switch (outcome.status) {
|
|
262
|
+
case "removed":
|
|
263
|
+
// 🔴 `stillLive` **必须上 wire**(design/203 v2 §6 F3)。它为真的意思是「墓碑落了,但这次调用
|
|
264
|
+
// 期间又落了一次新的批准,规则按 add-wins 仍然活着」——把它折进 200 不说,客户端会把一次
|
|
265
|
+
// **没有完成**的撤销读成完成,而那正是治理面最不能出错的地方。
|
|
266
|
+
sendJson(res, 200, { status: "removed", rev: outcome.rev, stillLive: outcome.stillLive });
|
|
267
|
+
return;
|
|
268
|
+
case "no-op": {
|
|
269
|
+
// 幂等:重复删 / 本来就没有,一律 200 `no-op`。**不是 404** —— 一个「这条规则存在过吗」的
|
|
270
|
+
// 404 就是一台存在性 oracle(operator 越权域下更是跨租户的那种),而删除对「有没有过」
|
|
271
|
+
// 本来就不该作 oracle。客户端要判「我删到东西了吗」,读 `status` 即可。
|
|
272
|
+
//
|
|
273
|
+
// 🔴 `stillLive` 这一支**也要有**(codex 对抗复审 R1-F2,验真后修)。core 的原语在初始快照里
|
|
274
|
+
// 找不到匹配 dot 时**立刻**回 no-op —— 它不做 removed 那支的读回。于是「快照之后、应答之前落了
|
|
275
|
+
// 一次批准」的窗口里,规则**是活的**而端点说 no-op,客户端合理地读成「已经没有这条规则了」。
|
|
276
|
+
// 那与 F3 修好的 removed+stillLive 是**同一个**危害,只是走了另一支。
|
|
277
|
+
// 处置 = server 自己补一次读回,把**观察到的**事实如实上 wire;两支 200 的键集因此同形,消费端
|
|
278
|
+
// 一套分支读得通。读回失败不改判(no-op 本身是 core 裁定的)⇒ 保守报 `stillLive:false`?**不**:
|
|
279
|
+
// 那是编一个结论。读不出来就让它抛,由本域的全局错误臂报 500 —— 一个说不清结果的撤销不该回 200。
|
|
280
|
+
// 让 core 的原语自己报这一位是更根治的形,已作为上游请托登记(server 不改上游语义,[2862])。
|
|
281
|
+
const stillLive = (await lane.listRules(target.principal)).rows.some((r) => r.rule === parsed.data.rule && r.scope === serializeRuleScope(scope));
|
|
282
|
+
sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case "failed":
|
|
286
|
+
// core 对 `failed` 的承诺是「读回确认什么都没写」或「读回本身也失败了,结局不定」——两种都是
|
|
287
|
+
// **重试可改判**的,所以 503 而不是 500。理由与 `state.rule_import_retry` 同族。
|
|
288
|
+
logger.info("rule revoke failed", { error: outcome.error });
|
|
289
|
+
sendError(res, 503, "state.rule_remove_failed", "the rule could not be revoked right now — retry, then reconcile with GET /v1/rules");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=rules.js.map
|