@sema-agent/server 7.4.0 → 7.5.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/USAGE.md +43 -0
- package/dist/approval-card.d.ts +15 -3
- package/dist/approval-card.js +41 -7
- package/dist/approval-reconciler.d.ts +108 -11
- package/dist/approval-reconciler.js +146 -19
- package/dist/boot/coordinators.js +10 -2
- package/dist/boot/org-memory.d.ts +6 -0
- package/dist/boot/org-memory.js +1 -1
- package/dist/boot/reapers.d.ts +2 -0
- package/dist/boot/reapers.js +11 -4
- package/dist/boot/resolve-spec.d.ts +3 -2
- package/dist/boot/resolve-spec.js +132 -32
- package/dist/boot/runner-deps.d.ts +23 -1
- package/dist/boot/runner-deps.js +8 -11
- package/dist/boot/workflow-orchestration.d.ts +8 -3
- package/dist/boot/workflow-orchestration.js +23 -1
- package/dist/config-center/apply-effective.js +33 -10
- package/dist/config-types.d.ts +24 -8
- package/dist/config.d.ts +6 -1
- package/dist/config.js +56 -11
- package/dist/env-facts.d.ts +3 -1
- package/dist/env-facts.js +3 -1
- package/dist/fleet/fleet-bus.d.ts +6 -1
- package/dist/fleet/fleet-bus.js +25 -3
- package/dist/governance-ask-marks.d.ts +31 -0
- package/dist/governance-ask-marks.js +122 -0
- package/dist/hooks/hook-runner.d.ts +28 -0
- package/dist/hooks/hook-runner.js +149 -25
- package/dist/http/routes/diagnostics.js +10 -5
- package/dist/http/routes/memory-policy.d.ts +2 -1
- package/dist/http/routes/memory-policy.js +77 -13
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/tasks.js +59 -22
- package/dist/http/server.d.ts +5 -0
- package/dist/http/server.js +23 -12
- package/dist/http/wire-types.d.ts +7 -2
- package/dist/main.js +17 -5
- package/dist/observability/fail-open.d.ts +13 -2
- package/dist/observability/fail-open.js +15 -4
- package/dist/observability/prompt-manifest.d.ts +5 -1
- package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
- package/dist/orchestration/workflow-notify-journal.js +137 -32
- package/dist/parked-decide.js +9 -4
- package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
- package/dist/plugins/approval-ask-store-memory.js +3 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
- package/dist/plugins/approval-ask-store-sql.js +9 -2
- package/dist/plugins/background-shell-support.d.ts +1 -1
- package/dist/plugins/background-shell-support.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
- package/dist/plugins/checkpoint-store-sql.js +71 -11
- package/dist/plugins/local-checkpoint-store.d.ts +20 -1
- package/dist/plugins/local-checkpoint-store.js +19 -0
- package/dist/plugins/mailbox-store-sql.d.ts +4 -10
- package/dist/plugins/mailbox-store-sql.js +57 -4
- package/dist/runs.d.ts +8 -0
- package/dist/runs.js +15 -2
- package/dist/runtime-governance.d.ts +18 -0
- package/dist/runtime-governance.js +90 -3
- package/dist/task-settings.d.ts +3 -9
- package/dist/task-settings.js +16 -13
- package/dist/tool-approval.d.ts +33 -6
- package/dist/tool-approval.js +80 -23
- package/dist/trace/core-keyset-guard.d.ts +17 -3
- package/package.json +3 -3
- package/dist/boot/lexical-path-env.d.ts +0 -10
- package/dist/boot/lexical-path-env.js +0 -88
- package/dist/capabilities/oa-tools.d.ts +0 -15
- package/dist/capabilities/oa-tools.js +0 -54
- package/dist/finance/cost-taxonomy.d.ts +0 -34
- package/dist/finance/cost-taxonomy.js +0 -26
- package/dist/plugins/approval-store-sql.d.ts +0 -116
- package/dist/plugins/approval-store-sql.js +0 -151
- package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
- package/dist/plugins/file-workflow-journal-store.js +0 -12
- package/dist/plugins/pg-approval-store.d.ts +0 -9
- package/dist/plugins/pg-approval-store.js +0 -9
- package/dist/plugins/pg-breaker-state.d.ts +0 -8
- package/dist/plugins/pg-breaker-state.js +0 -8
- package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
- package/dist/plugins/pg-checkpoint-store.js +0 -10
- package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/pg-file-snapshot-store.js +0 -8
- package/dist/plugins/pg-image-bake.d.ts +0 -12
- package/dist/plugins/pg-image-bake.js +0 -11
- package/dist/plugins/pg-image-index.d.ts +0 -12
- package/dist/plugins/pg-image-index.js +0 -11
- package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
- package/dist/plugins/pg-outcome-ledger.js +0 -11
- package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/pg-resume-anchor-store.js +0 -7
- package/dist/plugins/pg-run-store.d.ts +0 -9
- package/dist/plugins/pg-run-store.js +0 -9
- package/dist/plugins/pg-session-policy-store.d.ts +0 -7
- package/dist/plugins/pg-session-policy-store.js +0 -7
- package/dist/plugins/pg-session-store.d.ts +0 -12
- package/dist/plugins/pg-session-store.js +0 -12
- package/dist/plugins/pg-tool-result-store.d.ts +0 -9
- package/dist/plugins/pg-tool-result-store.js +0 -9
- package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-journal-store.js +0 -9
- package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-run-store.js +0 -9
- package/dist/plugins/tidb-approval-store.d.ts +0 -8
- package/dist/plugins/tidb-approval-store.js +0 -8
- package/dist/plugins/tidb-breaker-state.d.ts +0 -7
- package/dist/plugins/tidb-breaker-state.js +0 -7
- package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
- package/dist/plugins/tidb-checkpoint-store.js +0 -9
- package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/tidb-file-snapshot-store.js +0 -8
- package/dist/plugins/tidb-image-bake.d.ts +0 -12
- package/dist/plugins/tidb-image-bake.js +0 -11
- package/dist/plugins/tidb-image-index.d.ts +0 -12
- package/dist/plugins/tidb-image-index.js +0 -11
- package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
- package/dist/plugins/tidb-outcome-ledger.js +0 -12
- package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/tidb-resume-anchor-store.js +0 -7
- package/dist/plugins/tidb-run-store.d.ts +0 -10
- package/dist/plugins/tidb-run-store.js +0 -9
- package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
- package/dist/plugins/tidb-session-policy-store.js +0 -7
- package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
- package/dist/plugins/tidb-tool-result-store.js +0 -10
- package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/tidb-workflow-journal-store.js +0 -9
- package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
- package/dist/plugins/tidb-workflow-run-store.js +0 -10
- package/dist/plugins/workflow-journal-limits.d.ts +0 -12
- package/dist/plugins/workflow-journal-limits.js +0 -12
- package/dist/sema-registry.d.ts +0 -41
- package/dist/sema-registry.js +0 -40
|
@@ -65,6 +65,24 @@ export const MAX_HOOK_HTTP_HEADERS = 32;
|
|
|
65
65
|
export const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024;
|
|
66
66
|
/** 喂给模型的 deny 原因/additionalContext 的单条长度上限(字符)。 */
|
|
67
67
|
const MAX_HOOK_FEEDBACK_CHARS = 4_096;
|
|
68
|
+
/**
|
|
69
|
+
* 一次事件里所有 hook 上下文片段**聚合后**喂给模型的总量上限(字符)。
|
|
70
|
+
*
|
|
71
|
+
* 为什么单条帽不够:一次事件最多 {@link MAX_HOOK_ENTRIES_PER_EVENT} 条(parseHooksConfig 的广度闸),
|
|
72
|
+
* 每条最多推两段(`decision:"block"` 的 reason + `hookSpecificOutput.additionalContext`),
|
|
73
|
+
* {@link composeHooks} 再把部署槽与 task 槽的**成品**拼一次 —— 单条 4096 相乘后一次注入可达 ~256KB,
|
|
74
|
+
* 而这些字符是**逐次工具调用**进模型可见文本的(core `hooks.js` 的 preToolContext / `prepare-task.js`
|
|
75
|
+
* 的 tool_result 追加 / `runtask.js` 的 Stop follow-up),不是日志。
|
|
76
|
+
*
|
|
77
|
+
* 帽值取 10_000 的依据:模型可见文本在本栈的既有预算量纲就是 10k —— core `truncateError`
|
|
78
|
+
* (`src/core/tool-errors.ts`)的 10k 中截,逐字锚 CC 序列化器 `A7e` 的 `1e4`;#181 战役里 core 还要给
|
|
79
|
+
* `beforeToolCall` block reason 补一道同值兜底闸。取同一量级 = 「一次 hook 注入 ≤ 一份错误文本预算」,
|
|
80
|
+
* 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10_000;
|
|
83
|
+
/** 帽满时仍值得保留的最短残段(字符)。比这更短的尾巴不是信息:模型会把半句话读成完整指令,
|
|
84
|
+
* 宁可整条落进"省略"计数。 */
|
|
85
|
+
const MIN_HOOK_CONTEXT_PARTIAL_CHARS = 256;
|
|
68
86
|
/** PostToolUse payload 里 tool_response 文本投影的上限(字符)——工具结果可能是 MB 级。 */
|
|
69
87
|
const MAX_TOOL_RESPONSE_CHARS = 32_768;
|
|
70
88
|
/** stdin payload 里 tool_input JSON 投影的上限(字符;巨大 tool_input 经 JSON.stringify 造成
|
|
@@ -233,6 +251,104 @@ const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
|
|
|
233
251
|
function clip(s, n) {
|
|
234
252
|
return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
|
|
235
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* hook 上下文片段 → 喂给模型的单串。**所有** `additionalContext` 聚合点的唯一属主(此前是同一个
|
|
256
|
+
* `contexts.join("\n")` 表达式抄在二十余处,总量帽无处可挂 —— 那正是漂移成因)。
|
|
257
|
+
*
|
|
258
|
+
* 三条语义,都是承重的:
|
|
259
|
+
* 1. **顺序保留、只截尾**:先来的钩子先说话;一旦装不下就停,后面的条目一律不再挤进来(哪怕更短)。
|
|
260
|
+
* 按长度重排会让"第 3 条钩子的话"随别人的长度忽隐忽现,排障时无从复现。
|
|
261
|
+
* 2. **截断可见**:尾巴挂一行标记说明省了几条、丢了多少字符。静默丢弃会让 hook 作者以为自己的
|
|
262
|
+
* context 生效了,而模型那头根本没见过 —— 这类"以为配好了"的缺席比长文本本身更贵。
|
|
263
|
+
* 3. **标记不计预算**:与 core `truncateError` / CC `A7e` 同姿势(marker 不占那 10k),否则帽值的
|
|
264
|
+
* 含义会随标记文案长度漂。
|
|
265
|
+
*/
|
|
266
|
+
export function buildHookContext(parts) {
|
|
267
|
+
const present = parts.filter((p) => p.length > 0).map(readHookContextPart);
|
|
268
|
+
if (present.length === 0)
|
|
269
|
+
return undefined;
|
|
270
|
+
const kept = [];
|
|
271
|
+
let used = 0;
|
|
272
|
+
let truncating = false;
|
|
273
|
+
let messages = 0;
|
|
274
|
+
let omitted = 0;
|
|
275
|
+
let droppedChars = 0;
|
|
276
|
+
for (const part of present) {
|
|
277
|
+
// 内层的账先并进来(标记读回,见 {@link readHookContextPart}),再谈这一轮自己丢了什么。
|
|
278
|
+
messages += part.messages;
|
|
279
|
+
omitted += part.omitted;
|
|
280
|
+
droppedChars += part.dropped;
|
|
281
|
+
if (!truncating) {
|
|
282
|
+
const sep = kept.length > 0 ? 1 : 0; // join 的 "\n" 也占预算
|
|
283
|
+
const room = MAX_HOOK_CONTEXT_TOTAL_CHARS - used - sep;
|
|
284
|
+
if (room >= part.body.length) {
|
|
285
|
+
if (part.body.length > 0) {
|
|
286
|
+
kept.push(part.body);
|
|
287
|
+
used += sep + part.body.length;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
truncating = true;
|
|
292
|
+
// 只有**单条**发言可以切尾:切了它仍是"部分在场",账目诚实(dropped 记尾巴,omitted 不变)。
|
|
293
|
+
// 已聚合的成品代表内层多条、正文里却没有条目边界 —— 切一刀会把第 2..N 条整个抹掉却记 0 条省略,
|
|
294
|
+
// 标记恰好在最常走的两层合流路上说谎(codex 复审 R4,亲核属实)。⇒ 它要么整段留,要么整条丢。
|
|
295
|
+
if (part.messages === 1 && room >= MIN_HOOK_CONTEXT_PARTIAL_CHARS) {
|
|
296
|
+
kept.push(part.body.slice(0, room));
|
|
297
|
+
used += sep + room;
|
|
298
|
+
droppedChars += part.body.length - room;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// 整条不见 = 它代表的**全部**条目都不见。内层已经报过的那几条上面加过了,这里只补差额
|
|
303
|
+
// ——否则一个"3 of 5"的成品被整条丢掉会记成 8 条,报出"省掉的比总数还多"这种自相矛盾的账。
|
|
304
|
+
omitted += part.messages - part.omitted;
|
|
305
|
+
droppedChars += part.body.length;
|
|
306
|
+
}
|
|
307
|
+
const body = kept.join("\n");
|
|
308
|
+
if (droppedChars === 0)
|
|
309
|
+
return body;
|
|
310
|
+
const marker = `[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: ${omitted} of ${messages} hook messages omitted, ${droppedChars} characters dropped]`;
|
|
311
|
+
return kept.length > 0 ? `${body}\n${marker}` : marker;
|
|
312
|
+
}
|
|
313
|
+
/** 截断标记的尾锚形。帽值**逐字写进模式**(不是 `\d+`):只认"我们自己这一版才会写出的那一份",
|
|
314
|
+
* 别处飘来的同类文字连模式都不匹配。 */
|
|
315
|
+
const HOOK_CONTEXT_MARKER_RE = new RegExp(`\\n\\[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: (\\d+) of (\\d+) hook messages omitted, (\\d+) characters dropped\\]$`);
|
|
316
|
+
/** 读回标记时接受的数字上界(2^42)。比任何真值大若干数量级,又小到几十条相加也绝不越过安全整数
|
|
317
|
+
* —— 伪造的巨数因此根本进不来,合并加法也不必每一步复验溢出。 */
|
|
318
|
+
const MAX_HOOK_CONTEXT_PARSED_COUNT = 2 ** 42;
|
|
319
|
+
/**
|
|
320
|
+
* 片段 → 内容 + 它自带的截断账。
|
|
321
|
+
*
|
|
322
|
+
* 为什么必须能**读回**自己写的标记:`composeHooks` 会把 {@link buildHookContext} 的成品当片段再喂一次
|
|
323
|
+
* (部署槽在场即走这条路 —— 是常态不是边角)。标记按内容处理会犯两桩:①切在标记中间 ⇒ 模型读到半截
|
|
324
|
+
* 标记;②外层重新记账 ⇒ 内层丢掉的那一万多字符凭空消失,标记数字变成谎。标记既然不计预算,读回时也
|
|
325
|
+
* 不能算内容;把它携带的账并进外层,函数对自己的输出就是幂等的。
|
|
326
|
+
*
|
|
327
|
+
* 🔴 这把尺子对着的是**不可信文本**(钩子想吐什么就吐什么),所以只认"我们自己才写得出的那一份":
|
|
328
|
+
* 帽值逐字相等(在模式里)、`dropped ≥ 1`(我们从不在零丢弃时写标记)、`omitted ≤ messages`、
|
|
329
|
+
* `messages ≥ 1`、数字都在 {@link MAX_HOOK_CONTEXT_PARSED_COUNT} 内。任一条不合 ⇒ **整段当普通内容**。
|
|
330
|
+
* 认下之后也不会有"内容静默少一行":认下 ⇒ `dropped > 0` ⇒ 输出必然带标记 —— 摘掉的那一行总是被
|
|
331
|
+
* **重写**回去(数字可能被合并),从不是被删掉。伪造得刚好合规只会让记账口径偏,不改任何裁决;
|
|
332
|
+
* 而且钩子配置在单用户闸内,伪造者就是这份账的读者本人。
|
|
333
|
+
*/
|
|
334
|
+
function readHookContextPart(part) {
|
|
335
|
+
const m = HOOK_CONTEXT_MARKER_RE.exec(part);
|
|
336
|
+
if (!m)
|
|
337
|
+
return { body: part, messages: 1, omitted: 0, dropped: 0 };
|
|
338
|
+
const omitted = Number(m[1]);
|
|
339
|
+
const messages = Number(m[2]);
|
|
340
|
+
const dropped = Number(m[3]);
|
|
341
|
+
const sane = (n) => Number.isSafeInteger(n) && n <= MAX_HOOK_CONTEXT_PARSED_COUNT;
|
|
342
|
+
if (!sane(omitted) || !sane(messages) || !sane(dropped) || messages < 1 || omitted > messages || dropped < 1) {
|
|
343
|
+
return { body: part, messages: 1, omitted: 0, dropped: 0 };
|
|
344
|
+
}
|
|
345
|
+
return { body: part.slice(0, part.length - m[0].length), messages, omitted, dropped };
|
|
346
|
+
}
|
|
347
|
+
/** {@link buildHookContext} 的字段形(聚合点全是 `...spread` 进结果对象):空 → 不产字段。 */
|
|
348
|
+
function buildHookContextField(parts) {
|
|
349
|
+
const text = buildHookContext(parts);
|
|
350
|
+
return text === undefined ? {} : { additionalContext: text };
|
|
351
|
+
}
|
|
236
352
|
/** 跑一条 command hook:sh -c,stdin=payload JSON(CC-verbatim),超时 SIGKILL,输出各限 1MiB。 */
|
|
237
353
|
function runCommandHook(entry, payload, ctx) {
|
|
238
354
|
return new Promise((resolve) => {
|
|
@@ -736,7 +852,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
736
852
|
if (run.code === 2) {
|
|
737
853
|
// CC:exit 2 = 阻断,stderr 喂给模型
|
|
738
854
|
const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
|
|
739
|
-
return { action: "deny", message: reason, ...(contexts
|
|
855
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
740
856
|
}
|
|
741
857
|
if (run.code !== 0) {
|
|
742
858
|
ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
|
|
@@ -750,11 +866,11 @@ export function createTaskHooks(config, ctx) {
|
|
|
750
866
|
if (out.continue === false) {
|
|
751
867
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
752
868
|
// core 的 preToolUse seam 无"终止整个 run"能力(阶段一诚实近似:deny 本次调用)。
|
|
753
|
-
return { action: "deny", message: reason, ...(contexts
|
|
869
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
754
870
|
}
|
|
755
871
|
if (out.decision === "block") {
|
|
756
872
|
const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
757
|
-
return { action: "deny", message: reason, ...(contexts
|
|
873
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
758
874
|
}
|
|
759
875
|
const hso = out.hookSpecificOutput;
|
|
760
876
|
if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
|
|
@@ -764,7 +880,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
764
880
|
const decision = h.permissionDecision;
|
|
765
881
|
if (decision === "deny") {
|
|
766
882
|
const reason = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
767
|
-
return { action: "deny", message: reason, ...(contexts
|
|
883
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
768
884
|
}
|
|
769
885
|
if (decision === "ask") {
|
|
770
886
|
asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
|
|
@@ -798,7 +914,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
798
914
|
action: "ask",
|
|
799
915
|
message: asked,
|
|
800
916
|
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
801
|
-
...(contexts
|
|
917
|
+
...buildHookContextField(contexts),
|
|
802
918
|
};
|
|
803
919
|
}
|
|
804
920
|
if (unknownDecision) {
|
|
@@ -814,14 +930,14 @@ export function createTaskHooks(config, ctx) {
|
|
|
814
930
|
action: "ask",
|
|
815
931
|
message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
|
|
816
932
|
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
817
|
-
...(contexts
|
|
933
|
+
...buildHookContextField(contexts),
|
|
818
934
|
};
|
|
819
935
|
}
|
|
820
936
|
if (rewritten || contexts.length > 0) {
|
|
821
937
|
return {
|
|
822
938
|
action: "allow",
|
|
823
939
|
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
824
|
-
...(contexts
|
|
940
|
+
...buildHookContextField(contexts),
|
|
825
941
|
};
|
|
826
942
|
}
|
|
827
943
|
return undefined; // 无意见(≠ allow 短路;core 门照常裁决)
|
|
@@ -879,7 +995,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
879
995
|
}
|
|
880
996
|
}
|
|
881
997
|
}
|
|
882
|
-
|
|
998
|
+
const additionalContext = buildHookContext(contexts);
|
|
999
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
883
1000
|
};
|
|
884
1001
|
hooks.postToolUse = postToolUseHook;
|
|
885
1002
|
}
|
|
@@ -927,7 +1044,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
927
1044
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
928
1045
|
}
|
|
929
1046
|
}
|
|
930
|
-
|
|
1047
|
+
const additionalContext = buildHookContext(contexts);
|
|
1048
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
931
1049
|
};
|
|
932
1050
|
hooks.postToolUseFailure = postToolUseFailureHook;
|
|
933
1051
|
}
|
|
@@ -973,7 +1091,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
973
1091
|
if (out.hookSpecificOutput !== undefined)
|
|
974
1092
|
ctx.logger.warn("hook_result_unsupported", { event: "PostToolBatch" });
|
|
975
1093
|
}
|
|
976
|
-
|
|
1094
|
+
const additionalContext = buildHookContext(contexts);
|
|
1095
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
977
1096
|
};
|
|
978
1097
|
hooks.postToolBatch = postToolBatchHook;
|
|
979
1098
|
}
|
|
@@ -989,7 +1108,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
989
1108
|
for (const { run } of singles) {
|
|
990
1109
|
if (run.code === 2) {
|
|
991
1110
|
const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
|
|
992
|
-
return { block: reason, ...(contexts
|
|
1111
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
993
1112
|
}
|
|
994
1113
|
if (run.code !== 0) {
|
|
995
1114
|
ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
|
|
@@ -1003,11 +1122,11 @@ export function createTaskHooks(config, ctx) {
|
|
|
1003
1122
|
if (out.continue === false) {
|
|
1004
1123
|
// continue:false=终止整个 run;userPromptSubmit 的 block 语义正是"任务带理由收场"——同义直映射。
|
|
1005
1124
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
1006
|
-
return { block: reason, ...(contexts
|
|
1125
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
1007
1126
|
}
|
|
1008
1127
|
if (out.decision === "block") {
|
|
1009
1128
|
const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
1010
|
-
return { block: reason, ...(contexts
|
|
1129
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
1011
1130
|
}
|
|
1012
1131
|
const hso = out.hookSpecificOutput;
|
|
1013
1132
|
if (hso !== null && typeof hso === "object" && hso.hookEventName === "UserPromptSubmit") {
|
|
@@ -1016,7 +1135,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
1016
1135
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
1017
1136
|
}
|
|
1018
1137
|
}
|
|
1019
|
-
|
|
1138
|
+
const additionalContext = buildHookContext(contexts);
|
|
1139
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
1020
1140
|
};
|
|
1021
1141
|
hooks.userPromptSubmit = userPromptSubmitHook;
|
|
1022
1142
|
}
|
|
@@ -1180,7 +1300,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
1180
1300
|
return undefined;
|
|
1181
1301
|
return {
|
|
1182
1302
|
...(block !== undefined ? { block } : {}),
|
|
1183
|
-
...(contexts
|
|
1303
|
+
...buildHookContextField(contexts),
|
|
1184
1304
|
};
|
|
1185
1305
|
};
|
|
1186
1306
|
hooks.stop = stopHook;
|
|
@@ -1288,7 +1408,7 @@ export function composeHooks(deployment, task) {
|
|
|
1288
1408
|
if (r.additionalContext)
|
|
1289
1409
|
contexts.push(r.additionalContext);
|
|
1290
1410
|
if (r.action === "deny")
|
|
1291
|
-
return { ...r, ...(contexts
|
|
1411
|
+
return { ...r, ...buildHookContextField(contexts) };
|
|
1292
1412
|
if (r.action === "ask")
|
|
1293
1413
|
asked = r;
|
|
1294
1414
|
if (r.action === "allow" && r.updatedInput !== undefined) {
|
|
@@ -1301,14 +1421,14 @@ export function composeHooks(deployment, task) {
|
|
|
1301
1421
|
return {
|
|
1302
1422
|
...asked,
|
|
1303
1423
|
...(askedUpdated === undefined && current !== input ? { updatedInput: current } : {}),
|
|
1304
|
-
...(contexts
|
|
1424
|
+
...buildHookContextField(contexts),
|
|
1305
1425
|
};
|
|
1306
1426
|
}
|
|
1307
1427
|
if (current !== input || contexts.length > 0) {
|
|
1308
1428
|
return {
|
|
1309
1429
|
action: "allow",
|
|
1310
1430
|
...(current !== input ? { updatedInput: current } : {}),
|
|
1311
|
-
...(contexts
|
|
1431
|
+
...buildHookContextField(contexts),
|
|
1312
1432
|
};
|
|
1313
1433
|
}
|
|
1314
1434
|
return undefined;
|
|
@@ -1324,7 +1444,7 @@ export function composeHooks(deployment, task) {
|
|
|
1324
1444
|
const updatedOutput = rt?.updatedOutput ?? rd?.updatedOutput;
|
|
1325
1445
|
return {
|
|
1326
1446
|
...(updatedOutput ? { updatedOutput } : {}),
|
|
1327
|
-
...(contexts
|
|
1447
|
+
...buildHookContextField(contexts),
|
|
1328
1448
|
};
|
|
1329
1449
|
};
|
|
1330
1450
|
}
|
|
@@ -1333,7 +1453,8 @@ export function composeHooks(deployment, task) {
|
|
|
1333
1453
|
const rd = d.postToolUseFailure ? await d.postToolUseFailure(toolName, input, failure, ctx) : undefined;
|
|
1334
1454
|
const rt = t.postToolUseFailure ? await t.postToolUseFailure(toolName, input, failure, ctx) : undefined;
|
|
1335
1455
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1336
|
-
|
|
1456
|
+
const additionalContext = buildHookContext(contexts);
|
|
1457
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
1337
1458
|
};
|
|
1338
1459
|
}
|
|
1339
1460
|
if (d.postToolBatch || t.postToolBatch) {
|
|
@@ -1341,19 +1462,22 @@ export function composeHooks(deployment, task) {
|
|
|
1341
1462
|
const rd = d.postToolBatch ? await d.postToolBatch(calls) : undefined;
|
|
1342
1463
|
const rt = t.postToolBatch ? await t.postToolBatch(calls) : undefined;
|
|
1343
1464
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1344
|
-
|
|
1465
|
+
const additionalContext = buildHookContext(contexts);
|
|
1466
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
1345
1467
|
};
|
|
1346
1468
|
}
|
|
1347
1469
|
if (d.userPromptSubmit || t.userPromptSubmit) {
|
|
1348
1470
|
out.userPromptSubmit = async (prompt) => {
|
|
1349
1471
|
const rd = d.userPromptSubmit ? await d.userPromptSubmit(prompt) : undefined;
|
|
1472
|
+
// 短路的是**槽序**(部署拦下就不再跑 task 槽),不是预算:`additionalContext` 与 `block` 在 core
|
|
1473
|
+
// 是解耦两字段(见下方 stop 注),原样外送就是一条绕过总帽的道。
|
|
1350
1474
|
if (rd?.block)
|
|
1351
|
-
return rd;
|
|
1475
|
+
return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
|
|
1352
1476
|
const rt = t.userPromptSubmit ? await t.userPromptSubmit(prompt) : undefined;
|
|
1353
1477
|
if (!rd && !rt)
|
|
1354
1478
|
return undefined;
|
|
1355
1479
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1356
|
-
return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts
|
|
1480
|
+
return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
|
|
1357
1481
|
};
|
|
1358
1482
|
}
|
|
1359
1483
|
if (d.stop || t.stop) {
|
|
@@ -1362,12 +1486,12 @@ export function composeHooks(deployment, task) {
|
|
|
1362
1486
|
out.stop = async (sctx) => {
|
|
1363
1487
|
const rd = d.stop ? await d.stop(sctx) : undefined;
|
|
1364
1488
|
if (rd?.block)
|
|
1365
|
-
return rd;
|
|
1489
|
+
return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
|
|
1366
1490
|
const rt = t.stop ? await t.stop(sctx) : undefined;
|
|
1367
1491
|
if (!rd && !rt)
|
|
1368
1492
|
return undefined;
|
|
1369
1493
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1370
|
-
return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts
|
|
1494
|
+
return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
|
|
1371
1495
|
};
|
|
1372
1496
|
}
|
|
1373
1497
|
if (d.preCompact || t.preCompact) {
|
|
@@ -58,14 +58,19 @@ export function buildStaticWiringAudit(facts) {
|
|
|
58
58
|
// park 车道的**持久性读数**(codex 交叉复审 2026-08-07 R2-F2,验真后部分采纳)。`capable` 说的是
|
|
59
59
|
// 「有没有 checkpoint 店」、`effective` 说的是「这条部署形会不会真 park」—— 两者都为真时仍可能是一个
|
|
60
60
|
// 重启即丢的店,那样窗到期后被 park 的审批在重启后无法赎回,而流内审批协议正是拿 park 当降级目的地。
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
61
|
+
// ✅ **读数已转可信**(#168 件5,2026-08-07):树上三条 checkpoint 店(SQL 双生 + local 文件形)现在都
|
|
62
|
+
// 如实声明了 `durability`,所以这条读数不再对真持久部署恒假 —— 它现在说的就是字面意思。
|
|
63
|
+
//
|
|
64
|
+
// 🔴 仍然是 **warn 而不是拒启**,判据与 session 那条**刻意不同**,理由写在这里免得下一个人以为是漏改:
|
|
65
|
+
// session 那条对表的是「声明 durable × 介质不 durable」——两个已知量互相矛盾,拒启是查出了谎。这一条
|
|
66
|
+
// 对表的是「有没有人声明」,而 core 把**缺席**折成 `process-local`;一个合法的第三方/新增 checkpoint
|
|
67
|
+
// 店没表态时,拒启会挡住一个本来跑得好好的部署,而**没有任何旋钮**能让它继续启动(补偿为零 ⇒ 不升格)。
|
|
68
|
+
// 升格的前置件 = core 把「未声明」与「声明为进程内」分成两个可区分读数,或本仓给出一个显式承认旋钮。
|
|
64
69
|
if (facts.parkFacility && facts.manifest.parkLane.checkpointDurability !== "declared_durable") {
|
|
65
70
|
warnings.push(`park facility is enabled but the checkpoint store does not declare durable storage ` +
|
|
66
71
|
`(parkLane.checkpointDurability=${JSON.stringify(facts.manifest.parkLane.checkpointDurability ?? null)}). ` +
|
|
67
|
-
`A parked approval survives a restart only on a durable store
|
|
68
|
-
`Treat park-lane recovery as unproven until
|
|
72
|
+
`A parked approval survives a restart only on a durable store, and this reading folds "declared process-local" together with "declared nothing" — every in-tree adapter declares "durable", so a non-durable reading here means a store this build does not own. ` +
|
|
73
|
+
`Treat park-lane recovery as unproven until that adapter declares its durability.`);
|
|
69
74
|
}
|
|
70
75
|
if (facts.manifest.interaction.posture === "interactive" && facts.manifest.question.wired !== "wired") {
|
|
71
76
|
warnings.push(`interactionPosture declares "interactive" but no question delivery face is wired (question.wired=${JSON.stringify(facts.manifest.question.wired)}) — ` +
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* design/158 A9 域模块 —— memory 导出/同步 + 只读策略面。
|
|
3
3
|
*
|
|
4
4
|
* 路由:`GET /v1/memory/export`、`POST /v1/memory/sync/:scope`、`GET /v1/policy`。
|
|
5
|
-
* owner 门 = 「本人 user scope
|
|
5
|
+
* owner 门 = 「本人 user scope / 显式 operator / `org:` 键的目录成员」(见 {@link gateMemoryScope}),
|
|
6
|
+
* 越界一律 404(零存在性 oracle)。本域零可变状态。
|
|
6
7
|
*/
|
|
7
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
9
|
import type { RouteCtx } from "../route-ctx.js";
|
|
@@ -2,6 +2,70 @@ import { formatUserScope } from "@sema-agent/core";
|
|
|
2
2
|
import { parseMemorySyncRequest } from "../../memory-sync.js";
|
|
3
3
|
import { sendJson, sendError } from "../send.js";
|
|
4
4
|
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
5
|
+
const ORG_PREFIX = "org:";
|
|
6
|
+
/** 本面是否让目录参与判决。闭集穷举 switch:新增第三个模式是**编译**错误,不会静默落进某个默认臂
|
|
7
|
+
* ——这条门在授权轴上,静默默认值正是它不许有的东西。 */
|
|
8
|
+
function directoryDecidesThisFace(mode) {
|
|
9
|
+
switch (mode) {
|
|
10
|
+
case "enforce":
|
|
11
|
+
return true;
|
|
12
|
+
case "audit":
|
|
13
|
+
return false; // 见 gateMemoryScope 顶注的 codex R1 段
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* memory-policy 两面(export 读 / sync 写)共用的属主门 —— design/170 件A §7「第二消费者收编」。
|
|
18
|
+
*
|
|
19
|
+
* 收编前本门把 `org:*` 写死 operator-only(注逐字「membership waits for the S3 登记簿」),于是同一进程里
|
|
20
|
+
* 「谁属于 org:acme」有两个答案:core 准入门问授权目录、本面问 operatorPrincipals。收编后 `org:` 成员性
|
|
21
|
+
* 只由 `deps.orgMemoryDirectory` 回答(与准入 seam 同一个实例、同一份缓存)。
|
|
22
|
+
*
|
|
23
|
+
* 判决序(前两级刻意在目录之前 —— 它们不需要目录也成立,顺带让常路零额外 I/O):
|
|
24
|
+
* ① 本人 user 盘逐字匹配 ⇒ 通过;② 显式 operator ⇒ 通过(ops/迁移面不依赖目录在场);
|
|
25
|
+
* ③ `org:` 键 + 目录在场 + **enforce 模式** ⇒ 查目录:瞬时不可用照原样上抛;取数成功但无该键 ⇒
|
|
26
|
+
* `not_found`;`need==="write"` 另要条目 `write === true`(读授予不隐含写授予,与准入面 writeScope
|
|
27
|
+
* 收窄同源);④ 其余(`userproj:` 等目录没有答案的键、无目录源的部署、audit 模式)⇒ `not_found`。
|
|
28
|
+
*
|
|
29
|
+
* **audit 模式为什么在本面等于「不接目录」**(codex 对抗审 R1,2026-08-07):`audit` 是 operator 表态
|
|
30
|
+
* 「这份目录还没被验证过,先别拿它做判决」。拿未验证的目录去开真数据面(读)甚至写面,是这条表态能
|
|
31
|
+
* 造成的最严重后果——方向恰好反了;`audit=零行为变化` 的公开契约在本面因此读作「逐字保持收编前的
|
|
32
|
+
* operator-only」,连额外那一跳目录 I/O 和 enforce 才有的 503 响应形都不引入。org 面的诊断信号属于
|
|
33
|
+
* 准入侧的 `memory_admission_total{outcome,mode}`,不在本面另开一路。
|
|
34
|
+
*
|
|
35
|
+
* 拒绝一律 `not_found` 而非 403:本面的零存在性 oracle 纪律不变(与 run/trace 属主门同口径),
|
|
36
|
+
* 「不是成员」和「没这个 scope」对调用方必须不可区分。
|
|
37
|
+
*/
|
|
38
|
+
async function gateMemoryScope(deps, principal, scope, need) {
|
|
39
|
+
if (scope === formatUserScope(principal))
|
|
40
|
+
return { kind: "allow" };
|
|
41
|
+
if (explicitOperatorOk(principal, deps.config.operatorPrincipals))
|
|
42
|
+
return { kind: "allow" };
|
|
43
|
+
const directory = deps.orgMemoryDirectory;
|
|
44
|
+
if (!scope.startsWith(ORG_PREFIX) || directory === undefined || !directoryDecidesThisFace(deps.config.memoryOrgAdmissionMode)) {
|
|
45
|
+
return { kind: "not_found" };
|
|
46
|
+
}
|
|
47
|
+
const lookup = await directory.lookup(principal);
|
|
48
|
+
if (lookup.kind === "unavailable")
|
|
49
|
+
return { kind: "unavailable", retryAfterMs: lookup.retryAfterMs };
|
|
50
|
+
const grant = lookup.scopes[scope];
|
|
51
|
+
if (grant === undefined)
|
|
52
|
+
return { kind: "not_found" };
|
|
53
|
+
return need === "write" && grant.write !== true ? { kind: "not_found" } : { kind: "allow" };
|
|
54
|
+
}
|
|
55
|
+
/** 门的拒绝臂下发:瞬时 ⇒ 503 `memory.admission_required` + `retryAfterSec`(秒上取整,永不 0 —— C6;
|
|
56
|
+
* 族形与 sync 提交腿的同码响应逐字一致);终局 ⇒ 404 零 oracle。返回 true = 已应答,调用方须 return。 */
|
|
57
|
+
function sendScopeGateRefusal(res, gate) {
|
|
58
|
+
if (gate.kind === "allow")
|
|
59
|
+
return false;
|
|
60
|
+
if (gate.kind === "unavailable") {
|
|
61
|
+
const retryAfterSec = Math.max(1, Math.ceil(gate.retryAfterMs / 1000));
|
|
62
|
+
res.setHeader("retry-after", String(retryAfterSec)); // 体里有等待提示 ⇒ 标准头同值(与同步提交腿的同码应答逐字一致)
|
|
63
|
+
sendError(res, 503, "memory.admission_required", "org memory directory unavailable — authorization could not be established", { retryAfterSec });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
5
69
|
export async function handleMemoryPolicy(req, res, url, ctx) {
|
|
6
70
|
const miss = { fell: false };
|
|
7
71
|
await handleMemoryPolicyBody(req, res, url, ctx, miss);
|
|
@@ -17,11 +81,13 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
|
|
|
17
81
|
// C-additive 落点). After the global service-credential gate (a credential/SSO-JWT is already
|
|
18
82
|
// verified); the OWNER gate here is the tenant boundary: the verified principal may export exactly its OWN
|
|
19
83
|
// user disk (`scope === formatUserScope(principal)` — core's mint, byte-identical to memoryScopeFor's) or be
|
|
20
|
-
// an explicit operator (explicitOperatorOk, ops/migration face)
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
84
|
+
// an explicit operator (explicitOperatorOk, ops/migration face), or — design/170 件A §7 收编 — a
|
|
85
|
+
// DIRECTORY MEMBER of an `org:` key (the same org-memory directory instance core's admission seam reads,
|
|
86
|
+
// so one process holds ONE answer to "who belongs to org:acme"). Anything else = 404, NEVER 403 (zero
|
|
87
|
+
// existence oracle, run/trace owner-gate parity); a directory that cannot answer = 503, never 404.
|
|
88
|
+
// `userproj:*` stays operator-only by construction (the directory has no answer for that key shape).
|
|
89
|
+
// proj:* keys of repo-backed projects never live in this DB (铁律: git is their authority) — an owner
|
|
90
|
+
// asking for one just gets an empty set, honestly.
|
|
25
91
|
if (url === "/v1/memory/export" && req.method === "GET") {
|
|
26
92
|
// Auth BEFORE availability (501-before-auth precedent): the backend posture must not oracle
|
|
27
93
|
// to a caller who couldn't read anything anyway.
|
|
@@ -39,10 +105,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
|
|
|
39
105
|
sendError(res, 400, "request.query_invalid", "missing ?scope=<key>");
|
|
40
106
|
return;
|
|
41
107
|
}
|
|
42
|
-
if (
|
|
43
|
-
sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
|
|
108
|
+
if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "read")))
|
|
44
109
|
return;
|
|
45
|
-
}
|
|
46
110
|
try {
|
|
47
111
|
const entries = await deps.memoryExport(scope);
|
|
48
112
|
sendJson(res, 200, { scope, exportedAt: new Date().toISOString(), entries });
|
|
@@ -57,8 +121,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
|
|
|
57
121
|
// plan 随数据走——reconcile/nextSyncBaseline 是 core 纯函数,由持数据的本 server 在 performMemorySync
|
|
58
122
|
// (memory-sync.ts) 里直接调用;此处只持 HTTP 半场). Gate ORDER mirrors the export face verbatim:
|
|
59
123
|
// auth (401) → backend posture (501, file 形态诚实拒绝——单用户 file 面自己就是 TOC 侧,没有中心
|
|
60
|
-
// 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user
|
|
61
|
-
// operator;
|
|
124
|
+
// 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user 盘逐字匹配 / explicit
|
|
125
|
+
// operator / `org:` 键的目录成员且条目 write:true;userproj: 仍 operator-only) → 验型 (422 typed)。
|
|
62
126
|
if (req.method === "POST" && url.startsWith("/v1/memory/sync/")) {
|
|
63
127
|
const principal = gatedPrincipal(req, deps.config); // direct-door safe: verified identity, never the spoofable header
|
|
64
128
|
if (!principal) {
|
|
@@ -83,10 +147,10 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
|
|
|
83
147
|
sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
|
|
84
148
|
return;
|
|
85
149
|
}
|
|
86
|
-
|
|
87
|
-
|
|
150
|
+
// 写面:门与 export 面同一个 `gateMemoryScope`,只在 `need` 上分家 —— org 条目须 `write === true`
|
|
151
|
+
// 才放行(读授予不隐含写授予)。属主/operator 两级与读面逐字同判。
|
|
152
|
+
if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "write")))
|
|
88
153
|
return;
|
|
89
|
-
}
|
|
90
154
|
const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
|
|
91
155
|
const parsed = parseMemorySyncRequest(body, scope);
|
|
92
156
|
if (!parsed.ok) {
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -157,7 +157,7 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
|
|
|
157
157
|
// 🔴 开关谓词是**必须**的(codex 复审第二轮 high,2026-08-06):原按「关的时候表里本就没行 ⇒
|
|
158
158
|
// 无需分支」落地,但这个前提在**回滚**下不成立 —— 开着协议的副本(或更新版本)已经落下的
|
|
159
159
|
// `STREAM_PENDING` 行在关掉开关后**仍在表里**,无谓词的重放腿会把它们照常投上 wire,
|
|
160
|
-
//
|
|
160
|
+
// 「开关关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
|
|
161
161
|
// 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
|
|
162
162
|
preamble: async (signal) => {
|
|
163
163
|
if (!deps.config.streamApproval.enabled)
|