@sema-agent/server 7.3.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 +63 -0
- package/dist/approval-card.d.ts +15 -3
- package/dist/approval-card.js +41 -7
- package/dist/approval-reconciler.d.ts +120 -16
- package/dist/approval-reconciler.js +146 -19
- package/dist/boot/coordinators.js +13 -3
- package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
- package/dist/boot/deferred-sandbox-path-env.js +279 -0
- package/dist/boot/execution-env.js +11 -1
- 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 +175 -63
- 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/capabilities/center-prompts.js +4 -1
- package/dist/config-center/apply-effective.js +33 -10
- package/dist/config-types.d.ts +32 -9
- package/dist/config.d.ts +6 -1
- package/dist/config.js +65 -12
- package/dist/elicitation.js +5 -1
- 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 +180 -24
- package/dist/http/routes/diagnostics.d.ts +84 -0
- package/dist/http/routes/diagnostics.js +145 -0
- 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 +87 -29
- package/dist/http/server.d.ts +10 -0
- package/dist/http/server.js +29 -12
- package/dist/http/wire-types.d.ts +7 -2
- package/dist/main.js +51 -8
- package/dist/observability/fail-open.d.ts +109 -0
- package/dist/observability/fail-open.js +227 -0
- package/dist/observability/prompt-manifest.d.ts +17 -0
- package/dist/observability/prompt-manifest.js +8 -0
- 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/question.d.ts +18 -14
- package/dist/question.js +83 -34
- 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 +16 -21
- package/dist/task-settings.js +22 -19
- package/dist/tool-approval.d.ts +33 -6
- package/dist/tool-approval.js +95 -30
- package/dist/trace/core-keyset-guard.d.ts +17 -3
- package/dist/trace/project.d.ts +36 -1
- package/dist/trace/project.js +55 -2
- package/package.json +3 -3
- package/dist/boot/lexical-path-env.d.ts +0 -14
- package/dist/boot/lexical-path-env.js +0 -116
|
@@ -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) => {
|
|
@@ -706,6 +822,10 @@ export function createTaskHooks(config, ctx) {
|
|
|
706
822
|
let currentInput = input;
|
|
707
823
|
let rewritten = false;
|
|
708
824
|
let asked;
|
|
825
|
+
// #157 fail-open 治理(P 类,tag `server.hooks.pretooluse-unknown-permission-word`):本轮见过
|
|
826
|
+
// 词表外的 `permissionDecision`。安全域闭集词表的 miss 不是"没意见"——钩子作者**表过态**,只是
|
|
827
|
+
// 用了我们不认识的词;把它折成放行等于替他改判。置位后禁止本轮铸 `action:"allow"`(见末尾折叠)。
|
|
828
|
+
let unknownDecision = false;
|
|
709
829
|
const contexts = [];
|
|
710
830
|
const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000; // 跨全部组的事件级预算
|
|
711
831
|
// 逐条串行(deny 立即短路;updatedInput 串进后续条目 payload——确定性优先,CC 的并行聚合语义
|
|
@@ -732,7 +852,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
732
852
|
if (run.code === 2) {
|
|
733
853
|
// CC:exit 2 = 阻断,stderr 喂给模型
|
|
734
854
|
const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
|
|
735
|
-
return { action: "deny", message: reason, ...(contexts
|
|
855
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
736
856
|
}
|
|
737
857
|
if (run.code !== 0) {
|
|
738
858
|
ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
|
|
@@ -746,11 +866,11 @@ export function createTaskHooks(config, ctx) {
|
|
|
746
866
|
if (out.continue === false) {
|
|
747
867
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
748
868
|
// core 的 preToolUse seam 无"终止整个 run"能力(阶段一诚实近似:deny 本次调用)。
|
|
749
|
-
return { action: "deny", message: reason, ...(contexts
|
|
869
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
750
870
|
}
|
|
751
871
|
if (out.decision === "block") {
|
|
752
872
|
const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
753
|
-
return { action: "deny", message: reason, ...(contexts
|
|
873
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
754
874
|
}
|
|
755
875
|
const hso = out.hookSpecificOutput;
|
|
756
876
|
if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
|
|
@@ -760,7 +880,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
760
880
|
const decision = h.permissionDecision;
|
|
761
881
|
if (decision === "deny") {
|
|
762
882
|
const reason = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
763
|
-
return { action: "deny", message: reason, ...(contexts
|
|
883
|
+
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
764
884
|
}
|
|
765
885
|
if (decision === "ask") {
|
|
766
886
|
asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
|
|
@@ -771,6 +891,18 @@ export function createTaskHooks(config, ctx) {
|
|
|
771
891
|
rewritten = true;
|
|
772
892
|
}
|
|
773
893
|
}
|
|
894
|
+
else if (decision !== "deny" && decision !== "ask") {
|
|
895
|
+
// 闭集 miss(拼错大小写的 deny / 自造词 / 非字符串 / null)。该条目的裁决**整条不采信**:
|
|
896
|
+
// 它的 updatedInput 也不进串(上面的 if 已排除),并置位禁 allow。CC 的 `allow`/`deny`/
|
|
897
|
+
// `ask` 三词是唯一词表;新词入表须同时进这里,否则它在本仓的语义就是"不采信"。
|
|
898
|
+
unknownDecision = true;
|
|
899
|
+
ctx.logger.warn("hook_permission_decision_unknown", {
|
|
900
|
+
event: "PreToolUse",
|
|
901
|
+
decision: clip(typeof decision === "string" ? decision : typeof decision, 200),
|
|
902
|
+
tool: toolName,
|
|
903
|
+
effect: "hook ruling not honored (closed word set is allow|deny|ask); this call falls back to the engine gate",
|
|
904
|
+
});
|
|
905
|
+
}
|
|
774
906
|
}
|
|
775
907
|
}
|
|
776
908
|
}
|
|
@@ -782,14 +914,30 @@ export function createTaskHooks(config, ctx) {
|
|
|
782
914
|
action: "ask",
|
|
783
915
|
message: asked,
|
|
784
916
|
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
785
|
-
...(contexts
|
|
917
|
+
...buildHookContextField(contexts),
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
if (unknownDecision) {
|
|
921
|
+
// #157:本轮有一条**不采信**的裁决 ⇒ 一律折 `ask`(人在环)。两条都不行,各有其害:
|
|
922
|
+
// · `allow` —— 显式放行短路,引擎门不再裁决,等于替钩子作者把 DENY 改判成放行;
|
|
923
|
+
// · `undefined`(无裁决)—— 看着中立,其实**不是** fail-closed(codex 复审 HIGH,2026-08-07):
|
|
924
|
+
// 引擎拿到无裁决就走普通门,而 bypassPermissions / acceptEdits 档下普通门就是放行,
|
|
925
|
+
// 钩子作者写的 DENY 照样跑。词表 miss 的语义是「保护型判词坏了」而不是「钩子没表态」。
|
|
926
|
+
// 已有改写则随 `ask` 投递:改写通常正是 redact/clamp,丢了它 approve 后执行的是【原始】args
|
|
927
|
+
// —— `ask` 是唯一既 fail-closed 又能带 updatedInput 的裁决形。不采信的那条自己的改写不在其中
|
|
928
|
+
// (上面的 if 已把它挡在串外),否则一个坏判词就是一条免审的 args 改写通道。
|
|
929
|
+
return {
|
|
930
|
+
action: "ask",
|
|
931
|
+
message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
|
|
932
|
+
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
933
|
+
...buildHookContextField(contexts),
|
|
786
934
|
};
|
|
787
935
|
}
|
|
788
936
|
if (rewritten || contexts.length > 0) {
|
|
789
937
|
return {
|
|
790
938
|
action: "allow",
|
|
791
939
|
...(rewritten ? { updatedInput: currentInput } : {}),
|
|
792
|
-
...(contexts
|
|
940
|
+
...buildHookContextField(contexts),
|
|
793
941
|
};
|
|
794
942
|
}
|
|
795
943
|
return undefined; // 无意见(≠ allow 短路;core 门照常裁决)
|
|
@@ -847,7 +995,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
847
995
|
}
|
|
848
996
|
}
|
|
849
997
|
}
|
|
850
|
-
|
|
998
|
+
const additionalContext = buildHookContext(contexts);
|
|
999
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
851
1000
|
};
|
|
852
1001
|
hooks.postToolUse = postToolUseHook;
|
|
853
1002
|
}
|
|
@@ -895,7 +1044,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
895
1044
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
896
1045
|
}
|
|
897
1046
|
}
|
|
898
|
-
|
|
1047
|
+
const additionalContext = buildHookContext(contexts);
|
|
1048
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
899
1049
|
};
|
|
900
1050
|
hooks.postToolUseFailure = postToolUseFailureHook;
|
|
901
1051
|
}
|
|
@@ -941,7 +1091,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
941
1091
|
if (out.hookSpecificOutput !== undefined)
|
|
942
1092
|
ctx.logger.warn("hook_result_unsupported", { event: "PostToolBatch" });
|
|
943
1093
|
}
|
|
944
|
-
|
|
1094
|
+
const additionalContext = buildHookContext(contexts);
|
|
1095
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
945
1096
|
};
|
|
946
1097
|
hooks.postToolBatch = postToolBatchHook;
|
|
947
1098
|
}
|
|
@@ -957,7 +1108,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
957
1108
|
for (const { run } of singles) {
|
|
958
1109
|
if (run.code === 2) {
|
|
959
1110
|
const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
|
|
960
|
-
return { block: reason, ...(contexts
|
|
1111
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
961
1112
|
}
|
|
962
1113
|
if (run.code !== 0) {
|
|
963
1114
|
ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
|
|
@@ -971,11 +1122,11 @@ export function createTaskHooks(config, ctx) {
|
|
|
971
1122
|
if (out.continue === false) {
|
|
972
1123
|
// continue:false=终止整个 run;userPromptSubmit 的 block 语义正是"任务带理由收场"——同义直映射。
|
|
973
1124
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
974
|
-
return { block: reason, ...(contexts
|
|
1125
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
975
1126
|
}
|
|
976
1127
|
if (out.decision === "block") {
|
|
977
1128
|
const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
|
|
978
|
-
return { block: reason, ...(contexts
|
|
1129
|
+
return { block: reason, ...buildHookContextField(contexts) };
|
|
979
1130
|
}
|
|
980
1131
|
const hso = out.hookSpecificOutput;
|
|
981
1132
|
if (hso !== null && typeof hso === "object" && hso.hookEventName === "UserPromptSubmit") {
|
|
@@ -984,7 +1135,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
984
1135
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
985
1136
|
}
|
|
986
1137
|
}
|
|
987
|
-
|
|
1138
|
+
const additionalContext = buildHookContext(contexts);
|
|
1139
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
988
1140
|
};
|
|
989
1141
|
hooks.userPromptSubmit = userPromptSubmitHook;
|
|
990
1142
|
}
|
|
@@ -1148,7 +1300,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
1148
1300
|
return undefined;
|
|
1149
1301
|
return {
|
|
1150
1302
|
...(block !== undefined ? { block } : {}),
|
|
1151
|
-
...(contexts
|
|
1303
|
+
...buildHookContextField(contexts),
|
|
1152
1304
|
};
|
|
1153
1305
|
};
|
|
1154
1306
|
hooks.stop = stopHook;
|
|
@@ -1256,7 +1408,7 @@ export function composeHooks(deployment, task) {
|
|
|
1256
1408
|
if (r.additionalContext)
|
|
1257
1409
|
contexts.push(r.additionalContext);
|
|
1258
1410
|
if (r.action === "deny")
|
|
1259
|
-
return { ...r, ...(contexts
|
|
1411
|
+
return { ...r, ...buildHookContextField(contexts) };
|
|
1260
1412
|
if (r.action === "ask")
|
|
1261
1413
|
asked = r;
|
|
1262
1414
|
if (r.action === "allow" && r.updatedInput !== undefined) {
|
|
@@ -1269,14 +1421,14 @@ export function composeHooks(deployment, task) {
|
|
|
1269
1421
|
return {
|
|
1270
1422
|
...asked,
|
|
1271
1423
|
...(askedUpdated === undefined && current !== input ? { updatedInput: current } : {}),
|
|
1272
|
-
...(contexts
|
|
1424
|
+
...buildHookContextField(contexts),
|
|
1273
1425
|
};
|
|
1274
1426
|
}
|
|
1275
1427
|
if (current !== input || contexts.length > 0) {
|
|
1276
1428
|
return {
|
|
1277
1429
|
action: "allow",
|
|
1278
1430
|
...(current !== input ? { updatedInput: current } : {}),
|
|
1279
|
-
...(contexts
|
|
1431
|
+
...buildHookContextField(contexts),
|
|
1280
1432
|
};
|
|
1281
1433
|
}
|
|
1282
1434
|
return undefined;
|
|
@@ -1292,7 +1444,7 @@ export function composeHooks(deployment, task) {
|
|
|
1292
1444
|
const updatedOutput = rt?.updatedOutput ?? rd?.updatedOutput;
|
|
1293
1445
|
return {
|
|
1294
1446
|
...(updatedOutput ? { updatedOutput } : {}),
|
|
1295
|
-
...(contexts
|
|
1447
|
+
...buildHookContextField(contexts),
|
|
1296
1448
|
};
|
|
1297
1449
|
};
|
|
1298
1450
|
}
|
|
@@ -1301,7 +1453,8 @@ export function composeHooks(deployment, task) {
|
|
|
1301
1453
|
const rd = d.postToolUseFailure ? await d.postToolUseFailure(toolName, input, failure, ctx) : undefined;
|
|
1302
1454
|
const rt = t.postToolUseFailure ? await t.postToolUseFailure(toolName, input, failure, ctx) : undefined;
|
|
1303
1455
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1304
|
-
|
|
1456
|
+
const additionalContext = buildHookContext(contexts);
|
|
1457
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
1305
1458
|
};
|
|
1306
1459
|
}
|
|
1307
1460
|
if (d.postToolBatch || t.postToolBatch) {
|
|
@@ -1309,19 +1462,22 @@ export function composeHooks(deployment, task) {
|
|
|
1309
1462
|
const rd = d.postToolBatch ? await d.postToolBatch(calls) : undefined;
|
|
1310
1463
|
const rt = t.postToolBatch ? await t.postToolBatch(calls) : undefined;
|
|
1311
1464
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1312
|
-
|
|
1465
|
+
const additionalContext = buildHookContext(contexts);
|
|
1466
|
+
return additionalContext === undefined ? undefined : { additionalContext };
|
|
1313
1467
|
};
|
|
1314
1468
|
}
|
|
1315
1469
|
if (d.userPromptSubmit || t.userPromptSubmit) {
|
|
1316
1470
|
out.userPromptSubmit = async (prompt) => {
|
|
1317
1471
|
const rd = d.userPromptSubmit ? await d.userPromptSubmit(prompt) : undefined;
|
|
1472
|
+
// 短路的是**槽序**(部署拦下就不再跑 task 槽),不是预算:`additionalContext` 与 `block` 在 core
|
|
1473
|
+
// 是解耦两字段(见下方 stop 注),原样外送就是一条绕过总帽的道。
|
|
1318
1474
|
if (rd?.block)
|
|
1319
|
-
return rd;
|
|
1475
|
+
return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
|
|
1320
1476
|
const rt = t.userPromptSubmit ? await t.userPromptSubmit(prompt) : undefined;
|
|
1321
1477
|
if (!rd && !rt)
|
|
1322
1478
|
return undefined;
|
|
1323
1479
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1324
|
-
return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts
|
|
1480
|
+
return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
|
|
1325
1481
|
};
|
|
1326
1482
|
}
|
|
1327
1483
|
if (d.stop || t.stop) {
|
|
@@ -1330,12 +1486,12 @@ export function composeHooks(deployment, task) {
|
|
|
1330
1486
|
out.stop = async (sctx) => {
|
|
1331
1487
|
const rd = d.stop ? await d.stop(sctx) : undefined;
|
|
1332
1488
|
if (rd?.block)
|
|
1333
|
-
return rd;
|
|
1489
|
+
return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
|
|
1334
1490
|
const rt = t.stop ? await t.stop(sctx) : undefined;
|
|
1335
1491
|
if (!rd && !rt)
|
|
1336
1492
|
return undefined;
|
|
1337
1493
|
const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
|
|
1338
|
-
return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts
|
|
1494
|
+
return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
|
|
1339
1495
|
};
|
|
1340
1496
|
}
|
|
1341
1497
|
if (d.preCompact || t.preCompact) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #154 件二+件三 —— **装配自证**域:启动期静态自检(拒启判据)与 operator 诊断读面
|
|
3
|
+
* (`GET /v1/diagnostics/wiring`)。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 两件为什么同住一个模块:它们回答的是同一个问题(「这台 worker 到底接了什么线」),只是一个在 boot
|
|
6
|
+
* 用它拒绝启动、一个在运行期把它读给 operator。分成两处 = 拒启用的判据与诊断报的读数会各自演化,而
|
|
7
|
+
* 「拒启说没矛盾、诊断页显示矛盾」正是本设计要消灭的那类装配谎言。故 boot 半场是本文件导出的**纯函数**
|
|
8
|
+
* (可单测、无 I/O),路由半场只是它的读口。
|
|
9
|
+
*
|
|
10
|
+
* 分层:本模块不值 import `server.ts`(那条边会闭合运行时装载环),对它只有 `import type`。
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import type { WiringManifest } from "@sema-agent/core";
|
|
14
|
+
import { type StreamApprovalGate, type StreamApprovalGateInput } from "../../tool-approval.js";
|
|
15
|
+
import type { RouteCtx } from "../route-ctx.js";
|
|
16
|
+
/** 流内审批门的读数形:上场即 `"active"`,否则是那五个合取项里**第一个**不满足的原因词
|
|
17
|
+
* (与 `resolveStreamApprovalGate` 同一闭集,新增 reason 在此处编译红)。 */
|
|
18
|
+
export type StreamApprovalGateReading = "active" | Extract<StreamApprovalGate, {
|
|
19
|
+
active: false;
|
|
20
|
+
}>["reason"];
|
|
21
|
+
/** server 自己的装配谓词读数(core 的静态半场之外的那一半:本仓在这台 worker 上做出的判断)。 */
|
|
22
|
+
export interface ServerWiringGates {
|
|
23
|
+
streamApproval: StreamApprovalGateReading;
|
|
24
|
+
durableApproval: boolean;
|
|
25
|
+
checkpointStore: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** `buildServerWiringGates` 的入参 = 三个谓词各自的**真输入**(不接受已算好的布尔:那样这里读到的就
|
|
28
|
+
* 不再是装配现场的事实,而是别处算完可能已漂的复述)。 */
|
|
29
|
+
export interface ServerWiringGateInput {
|
|
30
|
+
toolApprovalEnabled: boolean;
|
|
31
|
+
streamApprovalEnabled: boolean;
|
|
32
|
+
/** 谓词自己的入参类型(结构形,不 import store 依赖树)—— 取型而非重述,两处不可能漂。 */
|
|
33
|
+
backend: StreamApprovalGateInput["backend"];
|
|
34
|
+
/** 在场性即事实;本面不取实例,故 `unknown` 足够(取窄型会把整棵 store 类型树拖进诊断域)。 */
|
|
35
|
+
checkpointStore: unknown;
|
|
36
|
+
durableApproval: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** 纯数据产物(`build*`):三个门的当前读数。`streamApproval` 走**单一谓词**
|
|
39
|
+
* `resolveStreamApprovalGate` —— 与 `/v1/capabilities` 的 `streamApproval` 位、与回决口的 501 门同一个
|
|
40
|
+
* 符号,所以诊断页读到的与消费端撞到的行为不可能不同。 */
|
|
41
|
+
export declare function buildServerWiringGates(input: ServerWiringGateInput): ServerWiringGates;
|
|
42
|
+
/** 启动自检的判决:`refusals` 非空 ⇒ 拒启(装配谎言);`warnings` ⇒ 留痕但放行。 */
|
|
43
|
+
export interface StaticWiringAudit {
|
|
44
|
+
refusals: readonly string[];
|
|
45
|
+
warnings: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** 自检输入 = core 的静态半场 + 我方装配现场的两件事实。 */
|
|
48
|
+
export interface StaticWiringFacts {
|
|
49
|
+
/** core `describeStaticWiring(deps, specTemplate)` 的产物。 */
|
|
50
|
+
manifest: WiringManifest;
|
|
51
|
+
/** 我方喂给 `resolveStreamApprovalGate` 的 `parkFacility` 实参(= checkpoint 店在场 ∧ DURABLE_APPROVAL)。 */
|
|
52
|
+
parkFacility: boolean;
|
|
53
|
+
/** store backend 的种类;`undefined` = env-only worker(无 backend)。 */
|
|
54
|
+
backendKind: "mysql" | "pg" | "local" | undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 三项对表(设计稿件二)。判据全是**两个独立谓词对同一件事的答案**,矛盾即装配谎言 —— 装配谎言不是
|
|
58
|
+
* 「配置不理想」,是「服务端对自己接了什么线的两套说法互斥」,继续启动只会把矛盾带进运行期的每一条腿。
|
|
59
|
+
*
|
|
60
|
+
* ① park 设施:core 从 `spec.checkpointStore ?? deps.checkpointStore` 判 `parkLane.capable`,我方从
|
|
61
|
+
* `backend.checkpoint ∧ DURABLE_APPROVAL` 判 `parkFacility` 并据此决定流内审批协议上不上场。两者
|
|
62
|
+
* 不等 ⇒ 协议按「窗到期能 park」上场而引擎侧根本不 park(结局是 core fail-closed deny),或反之
|
|
63
|
+
* 白白关掉一个可用的协议。`capable` 真而 `effective` 不真同样致命:能力在、这条部署形上却不会真的
|
|
64
|
+
* park,窗到期依旧无降级目的地。
|
|
65
|
+
* ② 会话店持久性:manifest 声明 `declared_durable` 而后端是 local / 根本没有后端 ⇒ 「重启存活」的声明
|
|
66
|
+
* 没有介质兑现。这条与 memory+durable 两条既有拒启同族(声明与介质必须对得上)。
|
|
67
|
+
* ③ interaction posture 声明 `interactive` 却没有任何 question 投递面 ⇒ 只 **warn**:core 在 prepare 时
|
|
68
|
+
* 会按腿拒,boot 先一步把话说成人话。不拒启,因为部署形本身可能只是暂时没接活体面,而 core 侧已有
|
|
69
|
+
* 兜底门 —— 拒启会把一个可运行的部署挡在门外。
|
|
70
|
+
* ④ park 车道的持久性读数不可信时 ⇒ **warn**(理由与这条判据为什么不是拒启,见其行内注)。
|
|
71
|
+
*
|
|
72
|
+
* 判决分两级是有判据的:**拒启**留给「两个谓词对同一件事互斥」(其中一个必错,继续跑就是带着谎言服务);
|
|
73
|
+
* **警告**留给「读数不可信/声明不完整」(信号本身还不足以断定谁错,据此拒启会挡住正常部署)。
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildStaticWiringAudit(facts: StaticWiringFacts): StaticWiringAudit;
|
|
76
|
+
/**
|
|
77
|
+
* boot 消费口:`refusals` 非空即抛(拒启,与 memory+durable 两条既有拒启同族的 fail-loud 形),
|
|
78
|
+
* `warnings` 逐条 warn。返回判决本体,调用方可以继续把读数打进启动 info 行。
|
|
79
|
+
*/
|
|
80
|
+
export declare function assertStaticWiringConsistent(facts: StaticWiringFacts, logger: {
|
|
81
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
82
|
+
} | undefined): StaticWiringAudit;
|
|
83
|
+
export declare function handleDiagnostics(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
84
|
+
//# sourceMappingURL=diagnostics.d.ts.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
2
|
+
import { sendJson, sendError } from "../send.js";
|
|
3
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
4
|
+
/** 纯数据产物(`build*`):三个门的当前读数。`streamApproval` 走**单一谓词**
|
|
5
|
+
* `resolveStreamApprovalGate` —— 与 `/v1/capabilities` 的 `streamApproval` 位、与回决口的 501 门同一个
|
|
6
|
+
* 符号,所以诊断页读到的与消费端撞到的行为不可能不同。 */
|
|
7
|
+
export function buildServerWiringGates(input) {
|
|
8
|
+
const gate = resolveStreamApprovalGate({
|
|
9
|
+
toolApprovalEnabled: input.toolApprovalEnabled,
|
|
10
|
+
streamApprovalEnabled: input.streamApprovalEnabled,
|
|
11
|
+
backend: input.backend,
|
|
12
|
+
parkFacility: input.checkpointStore !== undefined,
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
streamApproval: gate.active ? "active" : gate.reason,
|
|
16
|
+
durableApproval: input.durableApproval,
|
|
17
|
+
checkpointStore: input.checkpointStore !== undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 三项对表(设计稿件二)。判据全是**两个独立谓词对同一件事的答案**,矛盾即装配谎言 —— 装配谎言不是
|
|
22
|
+
* 「配置不理想」,是「服务端对自己接了什么线的两套说法互斥」,继续启动只会把矛盾带进运行期的每一条腿。
|
|
23
|
+
*
|
|
24
|
+
* ① park 设施:core 从 `spec.checkpointStore ?? deps.checkpointStore` 判 `parkLane.capable`,我方从
|
|
25
|
+
* `backend.checkpoint ∧ DURABLE_APPROVAL` 判 `parkFacility` 并据此决定流内审批协议上不上场。两者
|
|
26
|
+
* 不等 ⇒ 协议按「窗到期能 park」上场而引擎侧根本不 park(结局是 core fail-closed deny),或反之
|
|
27
|
+
* 白白关掉一个可用的协议。`capable` 真而 `effective` 不真同样致命:能力在、这条部署形上却不会真的
|
|
28
|
+
* park,窗到期依旧无降级目的地。
|
|
29
|
+
* ② 会话店持久性:manifest 声明 `declared_durable` 而后端是 local / 根本没有后端 ⇒ 「重启存活」的声明
|
|
30
|
+
* 没有介质兑现。这条与 memory+durable 两条既有拒启同族(声明与介质必须对得上)。
|
|
31
|
+
* ③ interaction posture 声明 `interactive` 却没有任何 question 投递面 ⇒ 只 **warn**:core 在 prepare 时
|
|
32
|
+
* 会按腿拒,boot 先一步把话说成人话。不拒启,因为部署形本身可能只是暂时没接活体面,而 core 侧已有
|
|
33
|
+
* 兜底门 —— 拒启会把一个可运行的部署挡在门外。
|
|
34
|
+
* ④ park 车道的持久性读数不可信时 ⇒ **warn**(理由与这条判据为什么不是拒启,见其行内注)。
|
|
35
|
+
*
|
|
36
|
+
* 判决分两级是有判据的:**拒启**留给「两个谓词对同一件事互斥」(其中一个必错,继续跑就是带着谎言服务);
|
|
37
|
+
* **警告**留给「读数不可信/声明不完整」(信号本身还不足以断定谁错,据此拒启会挡住正常部署)。
|
|
38
|
+
*/
|
|
39
|
+
export function buildStaticWiringAudit(facts) {
|
|
40
|
+
const refusals = [];
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const park = facts.manifest.parkLane;
|
|
43
|
+
if (park.capable !== facts.parkFacility) {
|
|
44
|
+
refusals.push(`park facility contradiction: the engine's static wiring says parkLane.capable=${park.capable} while this server assembled its approval gates with parkFacility=${facts.parkFacility}. ` +
|
|
45
|
+
`One of the two is wrong about the same checkpoint store — refusing to start rather than serving a worker whose approval lane can silently fail closed. ` +
|
|
46
|
+
`Check DURABLE_APPROVAL and the backend's checkpoint face (they are what both predicates read).`);
|
|
47
|
+
}
|
|
48
|
+
else if (facts.parkFacility && park.effective !== true) {
|
|
49
|
+
refusals.push(`park facility contradiction: this server assembled its approval gates with parkFacility=true, but the engine's static wiring resolves parkLane.effective=${JSON.stringify(park.effective)} (reasons: ${park.reasons.join(", ") || "none"}). ` +
|
|
50
|
+
`A window expiry would then have no degradation target and adjudicate as a fail-closed deny — refusing to start.`);
|
|
51
|
+
}
|
|
52
|
+
// 判据写成「不在持久集合里」而不是「是 local」是有方向的:将来多一种 backend kind 时,它会**拒启**
|
|
53
|
+
// 直到有人显式把它判进持久集,而不是静默通过 —— 声明与介质的对表,失败方向必须朝拒绝那边。
|
|
54
|
+
if (facts.manifest.session.store === "declared_durable" && facts.backendKind !== "mysql" && facts.backendKind !== "pg") {
|
|
55
|
+
refusals.push(`session store durability contradiction: the engine's static wiring reports session.store="declared_durable" while the store backend is ${facts.backendKind === undefined ? "absent (env-only worker)" : `"${facts.backendKind}"`}. ` +
|
|
56
|
+
`A durable declaration with no durable medium is a claim nothing can honor — refusing to start. Wire DB_BACKEND=mysql|pg, or fix the store's durability declaration.`);
|
|
57
|
+
}
|
|
58
|
+
// park 车道的**持久性读数**(codex 交叉复审 2026-08-07 R2-F2,验真后部分采纳)。`capable` 说的是
|
|
59
|
+
// 「有没有 checkpoint 店」、`effective` 说的是「这条部署形会不会真 park」—— 两者都为真时仍可能是一个
|
|
60
|
+
// 重启即丢的店,那样窗到期后被 park 的审批在重启后无法赎回,而流内审批协议正是拿 park 当降级目的地。
|
|
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 把「未声明」与「声明为进程内」分成两个可区分读数,或本仓给出一个显式承认旋钮。
|
|
69
|
+
if (facts.parkFacility && facts.manifest.parkLane.checkpointDurability !== "declared_durable") {
|
|
70
|
+
warnings.push(`park facility is enabled but the checkpoint store does not declare durable storage ` +
|
|
71
|
+
`(parkLane.checkpointDurability=${JSON.stringify(facts.manifest.parkLane.checkpointDurability ?? null)}). ` +
|
|
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.`);
|
|
74
|
+
}
|
|
75
|
+
if (facts.manifest.interaction.posture === "interactive" && facts.manifest.question.wired !== "wired") {
|
|
76
|
+
warnings.push(`interactionPosture declares "interactive" but no question delivery face is wired (question.wired=${JSON.stringify(facts.manifest.question.wired)}) — ` +
|
|
77
|
+
`every leg that asks a question will be refused by the engine at prepare time. Wire the live question seam (ASK_QUESTION_ENABLED), or declare the headless posture.`);
|
|
78
|
+
}
|
|
79
|
+
return { refusals, warnings };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* boot 消费口:`refusals` 非空即抛(拒启,与 memory+durable 两条既有拒启同族的 fail-loud 形),
|
|
83
|
+
* `warnings` 逐条 warn。返回判决本体,调用方可以继续把读数打进启动 info 行。
|
|
84
|
+
*/
|
|
85
|
+
export function assertStaticWiringConsistent(facts, logger) {
|
|
86
|
+
const audit = buildStaticWiringAudit(facts);
|
|
87
|
+
for (const w of audit.warnings)
|
|
88
|
+
logger?.warn("wiring_static_warning", { detail: w });
|
|
89
|
+
if (audit.refusals.length > 0) {
|
|
90
|
+
throw new Error(`static wiring self-check failed — refusing to start:\n${audit.refusals.map((r) => ` · ${r}`).join("\n")}`);
|
|
91
|
+
}
|
|
92
|
+
return audit;
|
|
93
|
+
}
|
|
94
|
+
export async function handleDiagnostics(req, res, url, ctx) {
|
|
95
|
+
const miss = { fell: false };
|
|
96
|
+
await handleDiagnosticsBody(req, res, url, ctx, miss);
|
|
97
|
+
return !miss.fell;
|
|
98
|
+
}
|
|
99
|
+
async function handleDiagnosticsBody(req, res, url, ctx, miss) {
|
|
100
|
+
const { deps } = ctx;
|
|
101
|
+
// 件三:「这台 worker 到底接了什么线」一屏看清 = core 的静态半场(含 `governance` 段 —— 本面**就是**
|
|
102
|
+
// 那个段的 operator 受众)+ server 自己三个装配谓词的读数。
|
|
103
|
+
//
|
|
104
|
+
// 门 = `explicitOperatorOk`(**不是** `isOperator`)。执法条件是**名单在场 ∨ 多租户**,不是只看
|
|
105
|
+
// `REQUIRE_PRINCIPAL`:本件的 SSE 腿对同一段 governance 恒判 `explicitOperatorOk`,若这里只在
|
|
106
|
+
// REQUIRE_PRINCIPAL 时判,「列了 operator 名单却没开 REQUIRE_PRINCIPAL」的部署上就成了流里关着、
|
|
107
|
+
// 端点上开着 —— 一个秘密两套门,严的那道白严(codex 交叉复审 2026-08-07 R2-F1,验真后采纳)。
|
|
108
|
+
// 名单为空 ∧ 非多租户 = 单租户 turnkey:该部署没有 operator 这个概念,唯一的用户就是 operator,
|
|
109
|
+
// 边界由全局 service-credential 门把守(与 `GET /v1/outcomes` 同裁)。
|
|
110
|
+
// 401 由更上游的全局门给出,本域只判 operator 与否。
|
|
111
|
+
//
|
|
112
|
+
// ⚠️ 与 SSE 腿的**残余不对称**(显式裁定,不是疏忽):turnkey 形上流里仍然谁都拿不到治理段,而本端点
|
|
113
|
+
// 给。理由=流是多订阅面且身份来自连接、端点是一次显式的 operator 请求;失败方向因此在流那侧更严。
|
|
114
|
+
//
|
|
115
|
+
// **不含** per-leg effective 历史:治理段只存在于 live 帧的 operator 投影里,durable 账本恒存剥后形
|
|
116
|
+
// (账本写时不知道未来读者是谁)。历史考古是另一件事,不在本面伪装成有。
|
|
117
|
+
if (req.method === "GET" && url === "/v1/diagnostics/wiring") {
|
|
118
|
+
if (deps.config.operatorPrincipals.length > 0 || deps.config.requirePrincipal) {
|
|
119
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
120
|
+
if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
|
|
121
|
+
sendError(res, 403, "auth.operator_only", "wiring diagnostics are operator-only on a multi-tenant worker");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 静态半场是 **composition root 在 boot 算的**(与拒启自检同一份产物,不可能与它不同)。缺席 ⇒ 这个
|
|
126
|
+
// 进程不是由 main.ts 装起来的(测试夹具形),此面诚实地不存在 —— 落到全局 404,而不是回一个空壳 200。
|
|
127
|
+
if (!deps.staticWiring) {
|
|
128
|
+
miss.fell = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
sendJson(res, 200, {
|
|
132
|
+
static: deps.staticWiring,
|
|
133
|
+
serverGates: buildServerWiringGates({
|
|
134
|
+
toolApprovalEnabled: deps.toolApproval !== undefined,
|
|
135
|
+
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
136
|
+
backend: deps.backend,
|
|
137
|
+
checkpointStore: deps.checkpointStore,
|
|
138
|
+
durableApproval: deps.config.durableApproval === true,
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
miss.fell = true;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=diagnostics.js.map
|
|
@@ -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";
|