@sema-agent/server 7.11.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/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/parked-revive-gate.d.ts +18 -2
- package/dist/boot/parked-revive-gate.js +136 -14
- 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/budget.js +22 -0
- package/dist/config-types.d.ts +17 -9
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -79
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +44 -9
- package/dist/http/routes/rules.d.ts +19 -7
- package/dist/http/routes/rules.js +180 -4
- package/dist/http/server.d.ts +4 -2
- package/dist/http/server.js +81 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +19 -1
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -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/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 +7 -0
- package/dist/plugins/permission-rule-store-sql.js +11 -0
- package/dist/plugins/store-backend.d.ts +12 -6
- package/dist/plugins/store-backend.js +58 -9
- package/dist/rules-consent.d.ts +69 -1
- package/dist/rules-consent.js +43 -1
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +6 -1
- package/dist/tool-approval.js +106 -27
- package/dist/trace/core-keyset-guard.d.ts +13 -2
- package/dist/trace/project.d.ts +19 -2
- package/dist/trace/project.js +24 -4
- package/package.json +2 -2
|
@@ -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
|
}>;
|
|
@@ -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;
|
|
@@ -85,14 +85,36 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
85
85
|
// org 折叠面),所以"说 yes ⟺ 面真能用"是结构性的:少一个,域不挂载、这里也翻假,消费方看到的
|
|
86
86
|
// 与它真能打通的永远一致。折叠面缺席时刻意不报 true —— 一个没有成员性判据的共享读面比没有更糟。
|
|
87
87
|
sharedMemory: Boolean(deps.sharedMemoryStore && deps.orgMemoryDirectory),
|
|
88
|
-
// design/183 form b:收编面(operator lane)
|
|
89
|
-
// adoptionLog 工厂在场(local backend
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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 ⟺ **四口都活**,不是「有一口活」。
|
|
95
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,
|
|
96
118
|
// CLI surfaces: /v1/policy is always served (config surface); /v1/usage returns real
|
|
97
119
|
// cumulative spend only when a per-principal quota is configured (else enabled:false).
|
|
98
120
|
policy: true,
|
|
@@ -139,14 +161,27 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
139
161
|
// routes (same gate as steer — the verbs share the registry + run store; retain-off runs 409 resume.retain_off).
|
|
140
162
|
subagentResume: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
|
|
141
163
|
// TaskRequest.settings (client per-request SemaSettings stamp) → projected into the engine TIGHTEN-ONLY
|
|
142
|
-
// (deny-wins, after the deployment + operator baseline). v1 wires permissions + permissionMode
|
|
143
|
-
//
|
|
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 无效而改用别的写法。
|
|
144
171
|
// `env`([2400] X-8):与真消费点同谓词——resolve-spec 的 setSessionShellEnv 只在 cwdHonored
|
|
145
172
|
// (单用户 host lane)注入 shell env;其余 lane 收到即警告忽略。此前硬编码 false 是「TaskSpec 无
|
|
146
173
|
// per-task env 注入」时代的陈旧断言(R-survey shellEnv seam 落地后失真=说没有但其实有,活能力被藏)。
|
|
147
174
|
// `hooks`(hook-runner 阶段一):TRUE 当本部署会真跑 hook 命令 = 单用户闸(hook 命令跑在
|
|
148
175
|
// worker host,class ② 能力授予;resolveSpec 的 taskHooks 装配同一谓词)。多租户 = false(收到警告忽略)。
|
|
149
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,
|
|
150
185
|
// [1476] R1 / [1478] R2: TaskRequest.appendSystemPrompt is accepted top-level → TaskSpec.appendSystemPrompt
|
|
151
186
|
// (first-class lane for the shell's product-knowledge block). A shell probes THIS flag: false/absent ⇒ fall
|
|
152
187
|
// back to the settings.outputStyle ride-along (older servers fold that into the same spec field).
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* #154 车二 ——
|
|
2
|
+
* #154 车二 + #203 §2 —— **规则车道的 wire 面**(design/179 §7 的导入半场 + design/203 的撤销半场)。
|
|
3
3
|
*
|
|
4
|
-
* POST
|
|
5
|
-
* POST
|
|
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`
|
|
6
8
|
*
|
|
7
9
|
* **lane = principal**(不是 operator):用户导的是**他自己**的规则,一条规则的语义就是「这个人自己
|
|
8
|
-
* 说过 yes」。operator
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* 说过 yes」。operator 在**扩权**这条路上没有位置——他既不该替租户扩权,也不该被要求代人点确认。
|
|
11
|
+
* 因此**导入两口**只认已验明的 principal,连 `explicitOperatorOk` 的旁路都不给:一个 operator 想给
|
|
12
|
+
* 自己导规则,用他自己的 principal 走同一条路即可(那时他就是租户)。
|
|
11
13
|
*
|
|
12
|
-
*
|
|
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`。
|
|
13
23
|
*
|
|
14
24
|
* 🔴 **四类拒绝在 wire 上同形 404**(信任边界 ①):unknown ticket / 别人的 ticket / 过期 / 已用过 ——
|
|
15
25
|
* 四个成因返回不同的码就是一台存在性 oracle(拿别人的票撞库能探到「它在别处存在」)。分类只进服务端
|
|
@@ -19,5 +29,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
19
29
|
import type { RouteCtx } from "../route-ctx.js";
|
|
20
30
|
export declare const RULES_CC_IMPORT_PREPARE_PATH = "/v1/rules/cc-import/prepare";
|
|
21
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";
|
|
22
34
|
export declare function handleRules(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
23
35
|
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { sendJson, sendError } from "../send.js";
|
|
3
|
-
import { gatedPrincipal } from "../principal-gate.js";
|
|
3
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
4
4
|
import { createLogger } from "../../observability/logger.js";
|
|
5
5
|
// 等待秒数的单一真源在车道:它同时被「放回认领时判这张票还撑不撑得过这段窗」用到(见那侧头注)。
|
|
6
|
-
|
|
6
|
+
// scope 的 wire 形也在车道(列举列 / 查询参数 / 删除体 / 游标四处同一份表示,见那侧头注)。
|
|
7
|
+
import { RULE_IMPORT_RETRY_AFTER_SEC, parseRuleScope, serializeRuleScope } from "../../rules-consent.js";
|
|
7
8
|
const logger = createLogger();
|
|
8
9
|
export const RULES_CC_IMPORT_PREPARE_PATH = "/v1/rules/cc-import/prepare";
|
|
9
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;
|
|
10
18
|
/** 一层 settings 的上限。一份 CC settings 是人手写的 JSON,不是数据集;16 KiB 宽到装得下几百条 allow,
|
|
11
19
|
* 窄到一次请求不能拿它当上传通道。层数同理(core v1 只读三层,给 3 就够,多给等于给一个放大面)。 */
|
|
12
20
|
const MAX_LAYER_CONTENT_CHARS = 16_384;
|
|
@@ -35,6 +43,44 @@ const CcImportPrepareBodySchema = z
|
|
|
35
43
|
})
|
|
36
44
|
.strict();
|
|
37
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}`;
|
|
38
84
|
export async function handleRules(req, res, url, ctx) {
|
|
39
85
|
const miss = { fell: false };
|
|
40
86
|
await handleRulesBody(req, res, url, ctx, miss);
|
|
@@ -45,15 +91,21 @@ async function handleRulesBody(req, res, url, ctx, miss) {
|
|
|
45
91
|
const { readJson, rateLimited } = ctx.helpers;
|
|
46
92
|
const isPrepare = req.method === "POST" && url === RULES_CC_IMPORT_PREPARE_PATH;
|
|
47
93
|
const isRedeem = req.method === "POST" && url === RULES_CC_IMPORT_REDEEM_PATH;
|
|
48
|
-
|
|
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) {
|
|
49
100
|
miss.fell = true;
|
|
50
101
|
return;
|
|
51
102
|
}
|
|
52
103
|
// 🔴 `prepare` 每次调用**都会**落一条 pending 审批记录 + 一张票 —— 它是本域唯一一条「一次请求 = 一行」
|
|
53
104
|
// 的写面,没有限流就是一条行洪口。与 `POST /v1/tasks/:id/asks/:askId/decision` 同姿势(限流在最前)。
|
|
105
|
+
// 撤销面两口同罩:`DELETE` 是写(每次铸一个 dot + 一条墓碑),`GET` 是整桶读 —— 两者都不该无限打。
|
|
54
106
|
if (rateLimited(req, res))
|
|
55
107
|
return;
|
|
56
|
-
// 鉴权**先于**可用性(501-before-auth 先例的反面同理)
|
|
108
|
+
// 鉴权**先于**可用性(501-before-auth 先例的反面同理):这四口没有可被探测的部署事实,但
|
|
57
109
|
// principal 是本域的全部授权基础,先立住它再谈别的。
|
|
58
110
|
const principal = gatedPrincipal(req, deps.config);
|
|
59
111
|
if (principal === undefined) {
|
|
@@ -65,6 +117,12 @@ async function handleRulesBody(req, res, url, ctx, miss) {
|
|
|
65
117
|
sendError(res, 501, "capability.rule_store_required", "this deployment has no permission-rule store wired");
|
|
66
118
|
return;
|
|
67
119
|
}
|
|
120
|
+
// ── 撤销面两口(#203 §2)──────────────────────────────────────────────────────────────────────
|
|
121
|
+
// 放在读体之前:`GET` 没有体,`readJson` 对它是一次白等。
|
|
122
|
+
if (isList) {
|
|
123
|
+
await handleRuleList(req, res, principal, lane, deps.config.operatorPrincipals);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
68
126
|
let rawBody;
|
|
69
127
|
try {
|
|
70
128
|
rawBody = await readJson(req);
|
|
@@ -73,6 +131,10 @@ async function handleRulesBody(req, res, url, ctx, miss) {
|
|
|
73
131
|
sendError(res, 400, "request.body_shape", "body must be valid JSON");
|
|
74
132
|
return;
|
|
75
133
|
}
|
|
134
|
+
if (isRevoke) {
|
|
135
|
+
await handleRuleRevoke(res, rawBody, principal, lane, deps.config.operatorPrincipals);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
76
138
|
if (isPrepare) {
|
|
77
139
|
const parsed = CcImportPrepareBodySchema.safeParse(rawBody);
|
|
78
140
|
if (!parsed.success) {
|
|
@@ -114,4 +176,118 @@ async function handleRulesBody(req, res, url, ctx, miss) {
|
|
|
114
176
|
}
|
|
115
177
|
sendJson(res, 200, { result: redeemed.result });
|
|
116
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
|
+
}
|
|
117
293
|
//# sourceMappingURL=rules.js.map
|
package/dist/http/server.d.ts
CHANGED
|
@@ -334,8 +334,10 @@ export interface ServiceSeamDeps {
|
|
|
334
334
|
parkedKnownAgentTypes?: ReadonlySet<string>;
|
|
335
335
|
/** [1596]/[1597] 跨进程父约束重供:decide 腿按部署配置重建 host 任务的同语义父策略链(exempt 锚=
|
|
336
336
|
* row.rootSessionId),经 core 1.396 的 `parkedResume.inheritedGate` 席位透传——缺席时重启后带
|
|
337
|
-
* requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。
|
|
338
|
-
|
|
337
|
+
* requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。
|
|
338
|
+
* ⚠️ **async**(A-010.1):重建要按本行 principal 现解 entitlement——理由在 parked-decide.ts 的
|
|
339
|
+
* `rebuildInheritedGate` 头注。 */
|
|
340
|
+
parkedReviveInheritedGate?: (row: import("@sema-agent/core").BackgroundAgentRecord) => Promise<unknown>;
|
|
339
341
|
}
|
|
340
342
|
/** **可观测面**:日志/指标/累加器。全体 fail-open——观测失败绝不改变业务面行为。 */
|
|
341
343
|
export interface ServiceObservabilityDeps {
|
package/dist/http/server.js
CHANGED
|
@@ -1082,6 +1082,13 @@ export function createHttpServer(rawDeps) {
|
|
|
1082
1082
|
sendError(res, 400, "request.field_invalid", "interactiveTools must be a boolean");
|
|
1083
1083
|
return null;
|
|
1084
1084
|
}
|
|
1085
|
+
// [1909]⑧ oneShot(core 5.23.0):boolean fail-loud,与它的姊妹旋钮 interactiveTools 逐字同姿势 ——
|
|
1086
|
+
// 两者说的是同一件事的两半(这次提交是不是 `-p` 一次性形)。非 boolean 静默丢会让调用方以为已经声明
|
|
1087
|
+
// 了一次性、模型却照旧被告知「结束回合等通知」= 后台结果丢失,正是本键存在的那个失败形。
|
|
1088
|
+
if (body.oneShot !== undefined && typeof body.oneShot !== "boolean") {
|
|
1089
|
+
sendError(res, 400, "request.field_invalid", "oneShot must be a boolean");
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1085
1092
|
// [854]② retainBackgroundProcesses:boolean fail-loud(邻居姿势 —— 非 boolean 静默变「不驻留」正是
|
|
1086
1093
|
// silent-drop 病灶);单用户闸/多租忽略在 resolveSpec(retainBackgroundProcessesFromBody)。
|
|
1087
1094
|
if (body.retainBackgroundProcesses !== undefined && typeof body.retainBackgroundProcesses !== "boolean") {
|
|
@@ -1271,7 +1278,15 @@ export function createHttpServer(rawDeps) {
|
|
|
1271
1278
|
* rebuild the taskConfig via the same `resolveSpec` path (from the sessionId-keyed checkpoint_ctx), then
|
|
1272
1279
|
* `runner.resume`. The capability token never leaves the service. Updates the parked run row to its new state.
|
|
1273
1280
|
*/
|
|
1274
|
-
async function resumeCheckpoint(sessionId, decision, reason,
|
|
1281
|
+
async function resumeCheckpoint(sessionId, decision, reason,
|
|
1282
|
+
// core `ApprovalSettledBy`(5.23.0):**这次结算的出处**,由调用方命名。REQUIRED —— core 的字段
|
|
1283
|
+
// 文档把义务钉在结算点上(「Fill it at every settlement site」),并说明集中推导正是「窗到期被报成
|
|
1284
|
+
// 另一个人拒绝」的成因。本函数有两个调用方且函数内**无从分辨**(sweep 与人为 decide 在这里唯一
|
|
1285
|
+
// 的差别是 `req` 缺席,那是「有没有 HTTP 请求」不是「谁结算的」),所以只能穿参。
|
|
1286
|
+
// ⚠️ core 的相容规则:`decision:"approve"` 只接受 `"human"` 或缺席 —— 「没人答所以它跑了」不是
|
|
1287
|
+
// 任何事实的记录。今天唯一传非 `"human"` 的调用方(SLA sweep)恒 deny,故不冲突;将来若有一个
|
|
1288
|
+
// 会 approve 的非人调用方,它该传的是**缺席**而不是一个自造的词。
|
|
1289
|
+
settledBy, req, // absent on the internal D-D SLA deny-sweep (resolveSpec rebuilds auth from the checkpoint, not req)
|
|
1275
1290
|
answer, binding, onResumeCommitted) {
|
|
1276
1291
|
const cs = deps.checkpointStore;
|
|
1277
1292
|
// No scope filter here BY DESIGN (council): an operator may view/decide ANY tenant's pending approval.
|
|
@@ -1388,7 +1403,9 @@ export function createHttpServer(rawDeps) {
|
|
|
1388
1403
|
...(deps.logger ? { warn: (event, fields) => deps.logger?.warn?.(event, fields) } : {}),
|
|
1389
1404
|
}, {
|
|
1390
1405
|
token, scope: cp.scope, sessionId: cp.sessionId, pendingAction: cp.pendingAction,
|
|
1391
|
-
|
|
1406
|
+
// #204 件6③:赎回腿是**另一个**结算点,出处同样穿参(它自己不猜)。今天走到这里的恒是人为
|
|
1407
|
+
// /decide —— 上面那道门写着 `req !== undefined`,而 sweep 恰是 req 缺席的那条腿。
|
|
1408
|
+
decision, settledBy, ...(reason !== undefined ? { reason } : {}),
|
|
1392
1409
|
...(binding !== undefined ? { binding: { ...(binding.boundCallId !== undefined ? { boundCallId: binding.boundCallId } : {}), ...(binding.boundInputHash !== undefined ? { boundInputHash: binding.boundInputHash } : {}), ...(binding.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}) } } : {}),
|
|
1393
1410
|
// remember 的 grant 闭包(onResumeCommitted 的唯一现役来源)在 parked 腿以行的 root/host
|
|
1394
1411
|
// 会话为锚透传([1591] 候裁② server 修——落子代会话则探针键永不相交,见 grantOnCommit 注);
|
|
@@ -1526,6 +1543,8 @@ export function createHttpServer(rawDeps) {
|
|
|
1526
1543
|
...(decision === "approve" && binding?.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}),
|
|
1527
1544
|
...(reason ? { reason } : {}),
|
|
1528
1545
|
...(answer !== undefined ? { answer } : {}),
|
|
1546
|
+
// #204 件6:结算出处逐字上 outcome(见参数顶注)。core 只接受它自己那三个词,server 不派生、不缺省。
|
|
1547
|
+
settledBy,
|
|
1529
1548
|
};
|
|
1530
1549
|
// Developer-mode verify (1.44) survives suspend/resume (core `resumeWithVerification`, design/51 P1-b): a
|
|
1531
1550
|
// task submitted with verify:true that hit an F4 gate must STILL be gated by the adversarial verifier on
|
|
@@ -2113,6 +2132,60 @@ export function createHttpServer(rawDeps) {
|
|
|
2113
2132
|
}
|
|
2114
2133
|
catch (e) {
|
|
2115
2134
|
if (e instanceof CheckpointError) {
|
|
2135
|
+
// ── A-010.2:`checkpoint.resume_aborted` = 取消终局(既非 terminal-failed,也**不是** retriable)──
|
|
2136
|
+
// core 只在 `taskConfig.signal.aborted === true` 时铸这个码(四个铸造点:pre-CAS 入口 / 编辑复裁
|
|
2137
|
+
// 竞速两处 / consumed 后 / reopen 成功后),而本腿递进去的 `signal` 只有 `cancelCtrl.signal` ——
|
|
2138
|
+
// 其唯一 abort 源是取消(inflightRuns 句柄 + 心跳轮询的持久取消旗)。所以收到该码 ⟺ 这条 run
|
|
2139
|
+
// 被取消了,四形的正确落点因此**同一个**:按取消结算(下面那道 pending/consumed 的判别式,在
|
|
2140
|
+
// 「该做什么」这个问题上是无差别的,故不做)。
|
|
2141
|
+
// · 归下面的 terminal 支(修前现状)= 200 的取消出路(本 catch 更下面的 `cancelCtrl.signal.aborted`
|
|
2142
|
+
// 分支)被本 CheckpointError 分支**遮蔽**:运维拿到 409 + core 裸报文,行虽落 failed 但
|
|
2143
|
+
// `errorCode` 不是 `cancelled` 且 `setTerminal` 连 TaskResult 都不写(null),pre-CAS 三形的
|
|
2144
|
+
// 卡还留 pending = 行终局 + 卡 pending 的孤儿对。
|
|
2145
|
+
// · 归 retriable(把该码加进下面的闭集)= 把一条**用户已取消**的 run 重新 park 成 `suspended`;
|
|
2146
|
+
// 下一次 /decide 的 markResuming 会重置取消旗并把活真跑起来 —— 取消被静默打败,执行车道回归。
|
|
2147
|
+
// ⛔ 修法刻意是「只给该码开一条臂」而不是把 `cancelCtrl.signal.aborted` 分支上提到本分支之前:
|
|
2148
|
+
// 同在 abort 期铸的 `checkpoint.reopen_failed` 两形(store 拒 reopen = 审批已终局消耗;reopen
|
|
2149
|
+
// 在飞失败 = 卡的状态从这里无法证明)带的信息比 "cancelled" 多,上提会把它们折没。
|
|
2150
|
+
if (e.code === "checkpoint.resume_aborted") {
|
|
2151
|
+
// 序与 /v1/runs/:id/cancel 的 cancelSuspended 逐字同向:**先**按 cancel 语义结算卡,**再**驱行
|
|
2152
|
+
// 终局(行终局会释放 task_active;反序会开一个「卡还 pending 而行已死」的窗)。cancel 的卡结算
|
|
2153
|
+
// 语义是 expire(≈ deny 的 reaper 终局),不是 resolve —— 取消意味着 run 死掉,deny-RESUME 会
|
|
2154
|
+
// 把拒绝喂回模型烧 token。consumed 那一形(core 3894)在这里天然是 no-op:CAS 只翻 pending。
|
|
2155
|
+
// ⚠️ 与 `/v1/runs/:id/cancel` 的 `cancelSuspended` **刻意分道**的一点(codex 对抗复审 R2-高2
|
|
2156
|
+
// 的处置):那只在 CAS 输掉时会改答 409,因为它站在腿**外面**——输掉意味着「可能有一条 decide
|
|
2157
|
+
// 腿正在跑」,它没资格驱行终局。本臂站在腿**里面**:markResuming 已经赢过、这条 run 归我们,
|
|
2158
|
+
// 而本码的语义是「它被取消了」。此刻 CAS 输掉只可能是卡已离开 pending(自己那次 resume 消费掉
|
|
2159
|
+
// 的 consumed 形,或 reaper 先收了)——两种都意味着**没有活审批留在外面**,行照驱终局才是对的:
|
|
2160
|
+
// 不驱才会把会话锁一直攥着(这正是 [868] 那条事故)。故不改结果面,但**必须留痕**。
|
|
2161
|
+
const cs2 = deps.checkpointStore;
|
|
2162
|
+
if (cs2) {
|
|
2163
|
+
try {
|
|
2164
|
+
const live = await cs2.get(token);
|
|
2165
|
+
if (live?.status === "pending" && !(await cs2.expire(token, live.scope))) {
|
|
2166
|
+
// CAS 输了:卡在我们读它与写它之间被别人(自己的 resume / reaper)settle 了。行为面无变化,
|
|
2167
|
+
// 但「取消时这张卡到底是谁收的」不留痕就永远查不出来。
|
|
2168
|
+
deps.logger?.warn?.("resume_cancel_checkpoint_settle_lost", { taskId, sessionId });
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
catch (cancelSettleErr) {
|
|
2172
|
+
// 卡结算是尽力而为:行终局才是释放锁的权威写,残留的 pending 卡另有 reaper 车道兜底。
|
|
2173
|
+
// 但**必须留痕** —— 静默吞掉的话「取消之后卡为什么还在」在遥测里没有任何线索。
|
|
2174
|
+
deps.logger?.warn?.("resume_cancel_checkpoint_settle_failed", {
|
|
2175
|
+
taskId, sessionId, err: cancelSettleErr instanceof Error ? cancelSettleErr.message : String(cancelSettleErr),
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
// `claimedRow &&` 与下面那道分支**同一个**守卫,不是多余的:驱行终局是有资格才做的写(只有
|
|
2180
|
+
// 抢到这条 run 的腿才有资格),而「本码只可能在 markResuming 之后铸」这件事是当前控制流的
|
|
2181
|
+
// 巧合、不是结构保证。守卫写出来,这条臂就不依赖那个巧合。
|
|
2182
|
+
if (claimedRow && taskId && deps.runStore) {
|
|
2183
|
+
const c = { taskId, sessionId, status: "failed", errorCode: "cancelled", errorMessage: e.message, stats: { turns: 0, tokens: 0 } };
|
|
2184
|
+
await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch(() => undefined);
|
|
2185
|
+
}
|
|
2186
|
+
settleFleet("failed"); // MF-Fleet (#7): 取消结算终局 → 行从 fleet 摘除
|
|
2187
|
+
return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
|
|
2188
|
+
}
|
|
2116
2189
|
// We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
|
|
2117
2190
|
// • TERMINAL (already_resolved / not_found / gate_mismatch / unsupported_version): the checkpoint is
|
|
2118
2191
|
// consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
|
|
@@ -2713,7 +2786,9 @@ export function createHttpServer(rawDeps) {
|
|
|
2713
2786
|
parkedSkipped += 1;
|
|
2714
2787
|
continue;
|
|
2715
2788
|
}
|
|
2716
|
-
|
|
2789
|
+
// #204 件6②:出处 = `"timeout"`(窗到期,**没有人**答)。此前这里与人为 /decide 共用一个不带
|
|
2790
|
+
// 出处的构造,于是下游把一次自动拒渲染成「有人拒绝了你」。
|
|
2791
|
+
await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied", "timeout").then((r) => { if (r.status >= 400)
|
|
2717
2792
|
deps.logger?.warn?.("deny_sweep_resume_blocked", { sessionId, status: r.status, body: r.body }); }, (e) => deps.logger?.warn?.("deny_sweep_resume_failed", { sessionId, err: e instanceof Error ? e.message : String(e) }));
|
|
2718
2793
|
}
|
|
2719
2794
|
if (parkedSkipped > 0)
|
|
@@ -2833,6 +2908,9 @@ const ROUTE_LABEL_LITERALS = new Set([
|
|
|
2833
2908
|
// ⚠️ 本段注释里也不许出现方括号后紧跟右圆括号的字符对:billable 门的表捕获正则是**非贪婪**的,
|
|
2834
2909
|
// 那两个字符会让它在这里提前收尾,后面的字面量整段落在门的视野外(写这条注时真的踩到过)。
|
|
2835
2910
|
"/v1/rules/cc-import/prepare", "/v1/rules/cc-import/redeem",
|
|
2911
|
+
// #203 §2:撤销面两口(GET 列举 / DELETE 撤销)共用一个字面路径。同样写字面量而不是引 `RULES_PATH`
|
|
2912
|
+
// 常量,理由与上一条逐字相同(名册门与 billable 申明门都扫源码文本)。
|
|
2913
|
+
"/v1/rules",
|
|
2836
2914
|
]);
|
|
2837
2915
|
/** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
|
|
2838
2916
|
* [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
|