@sema-agent/server 7.1.0 → 7.2.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 CHANGED
@@ -142,6 +142,7 @@ The server is configured entirely through environment variables. The most import
142
142
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
143
143
  | `SANDBOX_PKG_SOURCE` | `global` | Package sources inside sandboxes: `global` (official upstreams) / `cn` (China mirrors) / `custom` / `none` |
144
144
  | `SENSITIVE_WRITE_PATTERNS` | core's recommended set | Sensitive-path write deny list; comma-separated value replaces the set, `off` disables |
145
+ | `MANUAL_MODE_SHELL_GATE` | unset (off) | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence) |
145
146
  | `MODEL_CONNECT_TIMEOUT_MS` | `30000` | Gateway connect timeout |
146
147
  | `MODEL_FIRST_TOKEN_TIMEOUT_MS` | `120000` | First-token timeout |
147
148
  | `MODEL_IDLE_TIMEOUT_MS` | `300000` | Mid-stream idle timeout (`0` = off) |
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * 目录源三态(单选,授权面不做双源合并):
6
6
  * · center 在场(configCenter 且非 dryRun)⇒ 远程腿:per-principal caps 响应的 `orgMemory` 段
7
- * (fetchPrincipalCaps schema 读+透传;段键缺席=旧 center 能力握手 目录记 section_absent 瞬时)。
7
+ * ( `fetchPrincipalOrgMemory` —— caps 腿同一条 HTTP 请求形与同一道 principal 回声核,但**只
8
+ * 投影 org 段**读,治理面值漂移不连坐;段键缺席=旧 center 能力握手 ⇒ 目录记 section_absent 瞬时)。
8
9
  * **独立 fetch,不复用 caps 缓存条目**(C3:org 授权段自带判别联合缓存,两张表的失败域互不干扰
9
10
  * ——caps 腿的 deny 缓存/304 复用机制一旦共享,org 面的 gen 拒退与退避语义就会被 caps 语义污染)。
10
11
  * 流量记账:每 principal 每 grant TTL(60s)至多多一拍 caps 拉取,有界。
@@ -14,10 +15,12 @@
14
15
  * · 都缺 ⇒ resolver 缺席:core 对 request-origin org scope 铸 `memory.admission_required`(fail-closed),
15
16
  * deployment-origin 走 `deploymentMemoryScopes` 自证通道不受影响(单用户零迁移,v4 §0 伤害①)。
16
17
  *
17
- * 能力探测(C12,fail-loud):多租户(requirePrincipal)部署的 projects 登记簿里挂了 org 键 ——这些
18
- * 键在多租户下按 request-origin 盖章(N2 部署形态维)——而目录源缺席(含 configCenter dryRun 形)
19
- * 启动报错:该部署的每个 projectId 请求都会在 prepare 期被整拒,「audit 在跑但恒拒」不是可运行
20
- * 状态,响亮拒启动比静默全拒服务诚实(clay 三裁 [2687]:直接 BREAKING,不留兼容臂)。
18
+ * 能力探测(C12,fail-loud):多租户(requirePrincipal)部署 **且记忆面真点亮**(MEMORY_ENGINE +
19
+ * backend file——见下方 `memoryPlaneLive` 注) projects 登记簿里挂了 org 键 ——这些键在多租户下
20
+ * request-origin 盖章(N2 部署形态维)——而目录源缺席(含 configCenter dryRun 形)⇒ 启动报错:
21
+ * 该部署的每个 projectId 请求都会在 prepare 期被整拒,「audit 在跑但恒拒」不是可运行状态,响亮拒
22
+ * 启动比静默全拒服务诚实(clay 三裁 [2687]:直接 BREAKING,不留兼容臂)。记忆面 dark 的部署里这些
23
+ * 键根本到不了准入门,不在探测范围(复审 D-1)。
21
24
  */
22
25
  import type { RunnerDeps } from "@sema-agent/core";
23
26
  import type { ServiceConfig } from "../config.js";
@@ -1,4 +1,4 @@
1
- import { fetchPrincipalCaps } from "../config-center/http-client.js";
1
+ import { fetchPrincipalOrgMemory } from "../config-center/http-client.js";
2
2
  import { createMemoryScopeAdmission, createOrgMemoryDirectory, parseOrgDirectoryStatic } from "../org-memory-admission.js";
3
3
  const ORG_PREFIX = "org:";
4
4
  /** 部署自证 org scope 集(core 准入门的 deployment origin 词表):env 钉的 MEMORY_SCOPE(org 形时)
@@ -31,14 +31,11 @@ export function createOrgMemoryAdmissionWiring(opts) {
31
31
  }
32
32
  const directory = centerLeg !== undefined
33
33
  ? createOrgMemoryDirectory({
34
- fetchSection: async (principal) => {
35
- // etag 恒缺席 fetchPrincipalCaps 永不走 304 臂(返回 null 的唯一途径是条件请求),
36
- // 这里的 null 防御臂只为类型收口;C8 回声核与 schema 校验都在 fetchPrincipalCaps 内。
37
- const r = await fetchPrincipalCaps(centerLeg.baseUrl, centerLeg.token, principal, undefined, fetch, centerLeg.worker);
38
- if (r === null)
39
- throw new Error("config-center returned 304 to a non-conditional org-directory fetch");
40
- return r.orgMemory;
41
- },
34
+ // 复审 D-2 修:走 org 段专用读取口而不是整封 caps 读。两者同一条 HTTP 腿、同一道 C8 回声核,
35
+ // 差别只在**解析投影**:org 腿只读 principal+orgMemory 两字段,治理面(runtimeCaps/budget)
36
+ // 值漂移因此不再连坐 org 授权面(此前:一封 runtimeCaps 漂移的响应会让形状完全正确的 org
37
+ // 读不出来 目录记 fetch_failed 准入瞬时拒)。C3「两张表失败域互不干扰」至此是双向的。
38
+ fetchSection: (principal) => fetchPrincipalOrgMemory(centerLeg.baseUrl, centerLeg.token, principal, fetch, centerLeg.worker),
42
39
  grantTtlMs: config.memoryOrgGrantTtlMs,
43
40
  unavailableBackoffMs: config.memoryOrgUnavailableBackoffMs,
44
41
  })
@@ -49,8 +46,17 @@ export function createOrgMemoryAdmissionWiring(opts) {
49
46
  unavailableBackoffMs: config.memoryOrgUnavailableBackoffMs,
50
47
  })
51
48
  : undefined;
52
- // C12 能力探测:多租户 + 登记簿有 org 用面 + 目录源缺席 ⇒ 拒启动(见文件头注)。
53
- if (directory === undefined && config.requirePrincipal === true) {
49
+ // C12 能力探测:多租户 + **记忆面真点亮** + 登记簿有 org 用面 + 目录源缺席 ⇒ 拒启动(见文件头注)。
50
+ //
51
+ // 复审 D-1(7.1.0 后修):探测原先少了「记忆面是否真点亮」这一维,是假阳。多租户下记忆引擎只在
52
+ // `MEMORY_ENGINE` 开 **且** backend 非 file 时才装(boot/stores.ts:file 形在 requirePrincipal 下恒
53
+ // 走 memoryEngineBackendFor() = undefined,「file 底座无租户隔离」);记忆面 dark ⇒ resolve-spec 的
54
+ // `memoryEngine ? memorySpecForRequest(...) : undefined` 根本不铸 spec.memory ⇒ 登记簿里的 org 键
55
+ // 一次也到不了 core 准入门。此时拒启动既拦下了一个本来跑得好好的部署,报错串里那句「every request
56
+ // selecting these projects would be refused at prepare」也是假陈述——探测的成立前提就是「这些键真会
57
+ // 被请求带到准入门」。
58
+ const memoryPlaneLive = config.memoryEngineEnabled && config.memoryEngineBackend !== "file";
59
+ if (directory === undefined && config.requirePrincipal === true && memoryPlaneLive) {
54
60
  const orgProjects = Object.entries(config.projects)
55
61
  .filter(([, p]) => (p.defaultScopes ?? []).some((s) => s.startsWith(ORG_PREFIX)))
56
62
  .map(([id]) => id);
@@ -64,8 +70,13 @@ export function createOrgMemoryAdmissionWiring(opts) {
64
70
  ? createMemoryScopeAdmission(directory, {
65
71
  mode: config.memoryOrgAdmissionMode,
66
72
  // §5 观测:outcome 闭集 metric + 审计日志线(per-principal 归因走日志,principal 不入 metric label)。
73
+ // 复审 D-3(7.1.0 后修)两处:①`mode` 进 metric label —— 不然 audit 模式记的 would-refuse 与
74
+ // enforce 模式的真拒在同一条 `outcome="denied"` 曲线上不可辨,而「照算不真拒、看曲线」正是
75
+ // audit 这个诊断位存在的唯一用途(二值闭集,基数代价为零);②principal 真的进日志行 —— 归因
76
+ // 由 resolver 塞进 details(此前 details 里根本没有 principal,「per-principal 归因走日志」
77
+ // 这句注释与 CHANGELOG 7.0.0 的同款公开陈述都不成立:运维只看得到「某个 org scope 被拒」)。
67
78
  onOutcome: (outcome, details) => {
68
- metrics.inc("memory_admission_total", { outcome });
79
+ metrics.inc("memory_admission_total", { outcome, mode: config.memoryOrgAdmissionMode });
69
80
  if (outcome !== "ok" || details !== undefined)
70
81
  logger.warn("memory_admission_outcome", { outcome, ...details });
71
82
  },
@@ -1,4 +1,4 @@
1
- import { type MemoryBackend, type RunnerDeps } from "@sema-agent/core";
1
+ import { type MemoryBackend, type RunnerDeps, type ToolPolicy } from "@sema-agent/core";
2
2
  import type { PromptsDomainFaces } from "../prompts-domain-validate.js";
3
3
  import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
4
4
  import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
@@ -69,6 +69,21 @@ export interface ResolveSpecCtx {
69
69
  perTaskImage: PerTaskImageRegistry;
70
70
  sessionEnvSelection: SessionEnvironmentSelection;
71
71
  }
72
+ /** #152([2703] 案二):durable 部署上的 AskUserQuestion 门。活体面(QuestionCoordinator)缺席 ⇒ 原形
73
+ * `createDurableQuestionPolicy()`(恒 ask ⇒ 恒 durable park)。在场 ⇒ **判决时**按活流上下文分腿:
74
+ * 活流腿(bg/SSE,coordinator.runWithContext 包裹且投递面此刻可达,ALS 判)allow——工具执行落到
75
+ * RunnerDeps.onQuestion 的 coordinator,问正在 tail 流的活人;无活流腿(sync /v1/tasks、verify/cascade、
76
+ * durable resume 驱动、断连后的 detach 腿)ask——durable park 原语义逐字保留(此时执行只会拿到 headless
77
+ * 空答,park 才是把问题送到人面前的那条腿)。
78
+ * 工具名字面量与 server.ts 的 pre-CAS 守卫同源("AskUserQuestion",core 未根导出常量)。
79
+ * ⚠️ **单一属主**(复审 A2):AskUserQuestion 的 durable 判决只有这一处。任何需要「同参重建」这条判决的
80
+ * 地方(main.ts 的 parkedReviveInheritedGate 父约束链)必须调本工厂,不得自折 core 原形——两份拷贝里
81
+ * 只改一份正是本条 finding 的成因。**登记豁免一处**:leader worker 腿(src/leader/wire.ts provisionWorker)
82
+ * 自折 core 原形——该腿无活体问答面可装且 leader 不 import boot 层(分层),core 原形+sentinel 即其完整
83
+ * 语义;豁免注在彼处互指,接活体面之日必须并回本工厂。 */
84
+ export declare function createDurableQuestionGate(live: {
85
+ hasLiveContext(): boolean;
86
+ } | undefined): ToolPolicy;
72
87
  export declare function createResolveSpec(ctx: ResolveSpecCtx): ServiceDeps["resolveSpec"];
73
88
  export {};
74
89
  //# sourceMappingURL=resolve-spec.d.ts.map
@@ -50,11 +50,17 @@ import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationF
50
50
  import { redactSecrets } from "../trace/redact.js";
51
51
  /** #152([2703] 案二):durable 部署上的 AskUserQuestion 门。活体面(QuestionCoordinator)缺席 ⇒ 原形
52
52
  * `createDurableQuestionPolicy()`(恒 ask ⇒ 恒 durable park)。在场 ⇒ **判决时**按活流上下文分腿:
53
- * 活流腿(bg/SSE,coordinator.runWithContext 包裹,ALS 判)allow——工具执行落到 RunnerDeps.onQuestion
54
- * 的 coordinator,问正在 tail 流的活人;无活流腿(sync /v1/tasks、verify/cascade)ask——durable park
55
- * 原语义逐字保留(此时执行只会拿到 headless 空答,park 才是把问题送到人面前的那条腿)。
56
- * 工具名字面量与 server.ts 的 pre-CAS 守卫同源("AskUserQuestion",core 未根导出常量)。 */
57
- function durableQuestionPolicy(live) {
53
+ * 活流腿(bg/SSE,coordinator.runWithContext 包裹且投递面此刻可达,ALS 判)allow——工具执行落到
54
+ * RunnerDeps.onQuestion 的 coordinator,问正在 tail 流的活人;无活流腿(sync /v1/tasks、verify/cascade
55
+ * durable resume 驱动、断连后的 detach 腿)ask——durable park 原语义逐字保留(此时执行只会拿到 headless
56
+ * 空答,park 才是把问题送到人面前的那条腿)。
57
+ * 工具名字面量与 server.ts 的 pre-CAS 守卫同源("AskUserQuestion",core 未根导出常量)
58
+ * ⚠️ **单一属主**(复审 A2):AskUserQuestion 的 durable 判决只有这一处。任何需要「同参重建」这条判决的
59
+ * 地方(main.ts 的 parkedReviveInheritedGate 父约束链)必须调本工厂,不得自折 core 原形——两份拷贝里
60
+ * 只改一份正是本条 finding 的成因。**登记豁免一处**:leader worker 腿(src/leader/wire.ts provisionWorker)
61
+ * 自折 core 原形——该腿无活体问答面可装且 leader 不 import boot 层(分层),core 原形+sentinel 即其完整
62
+ * 语义;豁免注在彼处互指,接活体面之日必须并回本工厂。 */
63
+ export function createDurableQuestionGate(live) {
58
64
  if (live === undefined)
59
65
  return createDurableQuestionPolicy();
60
66
  return {
@@ -736,7 +742,7 @@ export function createResolveSpec(ctx) {
736
742
  // policy is LIVE-AWARE when the QuestionCoordinator is wired — a live-stream leg adjudicates `allow`
737
743
  // (the live human answers over the stream) instead of unconditionally parking; see durableQuestionPolicy.
738
744
  toolPolicy: durableEnabled
739
- ? combinePolicies(durableQuestionPolicy(liveQuestionFace), createDurableAskPolicy({
745
+ ? combinePolicies(createDurableQuestionGate(liveQuestionFace), createDurableAskPolicy({
740
746
  requireApproval: config.approvalRequire, deny: config.approvalDeny, autoBudget: config.approvalAutoBudget, neverAuto: config.approvalNeverAuto,
741
747
  // The probe key is the CONTINUED session (auth.sessionId — the same id that keys the
742
748
  // durable checkpoint /decide route). A fresh session (no body.sessionId) has no exemptions by
@@ -937,16 +943,16 @@ export function createResolveSpec(ctx) {
937
943
  throw new HttpError(422, `settings are tighten-only and cannot loosen the deployment policy: ${e.message}`);
938
944
  }
939
945
  }
940
- // subagent-hands follow-on (adversarial-review finding): core does NOT propagate
941
- // handsReadOnly/shellGate to a delegated subagent (SubagentToolOptions has NO read-only knob; subagent.js's child
942
- // runTask omits it). So a read-only (permissionMode=plan) parent that mounts the Task tool would let the CHILD get
943
- // WRITABLE hands now reachable because subRunner has an executionEnvFactory (the hands fix). That defeats plan
944
- // mode's read-only contract (a within-tenant gap, not a cross-tenant breach). Until core propagates it (relayed),
945
- // strip the delegation tools when this task is hands-read-only — a read-only run delegates nothing writable.
946
- // `stripDelegationTools` (runtime-governance.ts, tested there) matches the subagent tool ("Agent", core 1.202
947
- // canonical; legacy alias "Task") + Fork in CANONICAL space — the pre-1.202 raw `!== "Task"` filter here
948
- // silently stopped matching when core renamed the tool (fail-open: a plan-mode parent could delegate a
949
- // WRITABLE-hands child).
946
+ // subagent-hands follow-on (adversarial-review finding): a read-only (permissionMode=plan) parent that mounts
947
+ // the delegation tool used to let the CHILD get WRITABLE hands reachable once subRunner grew an
948
+ // executionEnvFactory which defeats plan mode's read-only contract. So a hands-read-only run mounts no
949
+ // delegation tool at all.
950
+ // 🔴 复审 2026-08-05 亲验安装包(core 5.13.x)后修口:当年那句「core 不把 handsReadOnly/shellGate 传给
951
+ // 子任务」**已过期** —— spec 现在两者都继承(subagent.js / prepare-task.js inheritedGateForChildren,
952
+ // 引文在 stripDelegationTools 的注释里)。本行因此是同向第二层(更严不更松),不是唯一防线;要拆得单独决定。
953
+ // `stripDelegationTools`(runtime-governance.ts,过滤器本体在那里被测钉住)按 core 单源常量
954
+ // `DEFAULT_SUBAGENT_TOOL_NAME`(="Agent")RAW 比对——5.0.0 RB-476 退役了折叠面,旧名 "Task"/"Fork"
955
+ // roster 层就 miss、不再需要归一(常量引用本身即防当年 1.202 改名时裸字符串失配的那类 fail-open)
950
956
  if (governed.handsReadOnly === true && Array.isArray(governed.tools)) {
951
957
  governed = { ...governed, tools: stripDelegationTools(governed.tools) };
952
958
  }
@@ -39,7 +39,7 @@
39
39
  * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
40
  * importers to update, which was done in the same commit.
41
41
  */
42
- export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
42
+ export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
43
  export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
44
  export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./restart-signal.js";
45
45
  export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
@@ -39,7 +39,7 @@
39
39
  * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
40
  * importers to update, which was done in the same commit.
41
41
  */
42
- export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
42
+ export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
43
  export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
44
  export { restartReasons, planeHasActiveTiers, modelPlaneChanged, } from "./restart-signal.js";
45
45
  export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
@@ -41,6 +41,25 @@ export declare function fetchPrincipalCaps(baseUrl: string, token: string, princ
41
41
  orgMemory?: unknown;
42
42
  etag?: string;
43
43
  } | null>;
44
+ /**
45
+ * design/170 件A —— 读 `?principal=` 响应里的 org 记忆解析段(org 目录腿的取数口)。
46
+ *
47
+ * 🔬 **为什么不复用 {@link fetchPrincipalCaps}**(复审车 D-2 修):C3「两张表失败域互不干扰」是在档
48
+ * 裁定,registry-core 的 `PrincipalCapsWire` 也正是为此把 org 段留成 `unknown`(「在本 schema 里校验
49
+ * 会把 org 段坏形连坐进 caps 腿」)。但隔离此前只做了**单向**:org 段坏不连坐 caps,caps 段坏却整体
50
+ * 连坐 org —— 治理面 `runtimeCaps`/`budget` 的一次值漂移会让整封 safeParse 失败、函数 throw,于是一个
51
+ * **形状完全正确**的 org 授权段读不出来,目录记 `fetch_failed`、准入铸瞬时拒。治理面的 fail-closed
52
+ * 极性是对的(它管的是「别静默放松治理」),但它不该决定 org 授权面的可用性。
53
+ *
54
+ * 所以这条腿用 {@link PrincipalOrgMemoryEnvelope} 只读它真正消费的两个字段:治理面漂移在这里是被
55
+ * `.passthrough()` 原样放过的未知键,不构成本腿的故障。段本身的形状校验仍在目录腿
56
+ * (`PrincipalOrgMemoryWire`),本腿保持薄透传不预判。
57
+ *
58
+ * 返回值三值语义:段键缺席 ⇒ `undefined`(旧 center 能力握手 ⇒ 目录判 `section_absent` 瞬时);
59
+ * 段在场 ⇒ 原值(空表 = 取数成功的负结果,与缺席不同格)。条件请求不适用(本腿恒不带 etag),
60
+ * 因此 304 在传输层就是 fail-loud。
61
+ */
62
+ export declare function fetchPrincipalOrgMemory(baseUrl: string, token: string, principal: string, fetchImpl?: typeof fetch, worker?: string): Promise<unknown>;
44
63
  /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
45
64
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
46
65
  export declare class ConfigCenterHttpError extends Error {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * config-center HTTP client — the transport calls the service makes against the config-center
3
- * (`fetchEffective`/`fetchPrincipalCaps`/`fetchSkillContent`/`fetchPromptArtifact`/`fetchPromptBlob`) plus
4
- * the shared HTTP-error type. Split out of `facade.ts` (design/158 A13, internal-lossless — the
3
+ * (`fetchEffective`/`fetchPrincipalCaps`/`fetchPrincipalOrgMemory`/`fetchSkillContent`/`fetchPromptArtifact`/
4
+ * `fetchPromptBlob`) plus the shared HTTP-error type. 后两条 `?principal=` 腿共用 `requestPrincipalView`
5
+ * 传输半场与 `assertPrincipalEcho` 回声核,只在**解析投影**上分家(见 `fetchPrincipalOrgMemory` 头注)。 Split out of `facade.ts` (design/158 A13, internal-lossless — the
5
6
  * facade re-exports every symbol below unchanged).
6
7
  */
7
8
  import { createHash } from "node:crypto";
@@ -72,44 +73,19 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
72
73
  * content-hashed (`p-<hash>`); an `if-none-match` poll is cheap. Mirrors `fetchEffective` (Bearer + 8s + scheme guard).
73
74
  */
74
75
  export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchImpl = fetch, worker) {
75
- const scheme = new URL(baseUrl).protocol;
76
- if (scheme !== "http:" && scheme !== "https:")
77
- throw new Error(`SEMA_REGISTRY_URL must be http(s), got "${scheme}"`);
78
- // `?principal=<id>[&worker=<self>]` — worker authorizes the wpt_<self> caps-only path; harmless on a full token.
79
- const params = new URLSearchParams({ principal });
80
- if (worker)
81
- params.set("worker", worker);
82
- const url = `${baseUrl.replace(/\/+$/, "")}/api/config/effective?${params.toString()}`;
83
- const res = await fetchImpl(url, {
84
- headers: { authorization: `Bearer ${token}`, ...(etag ? { "if-none-match": etag } : {}) },
85
- signal: AbortSignal.timeout(8000),
86
- });
87
- if (res.status === 304) {
88
- // A 304 is only valid as a reply to OUR conditional. If we sent no etag and still got 304, center is
89
- // misbehaving — DON'T treat it as "unchanged" (that surfaces undefined caps = no restriction = fail OPEN).
90
- // Throw so the resolver fail-closes (review B). With an etag, 304 = genuine "unchanged" → null.
91
- if (!etag)
92
- throw new Error("config-center returned 304 to a non-conditional principal-caps request");
76
+ const raw = await requestPrincipalView(baseUrl, token, principal, etag, fetchImpl, worker);
77
+ if (raw === null)
93
78
  return null;
94
- }
95
- if (!res.ok)
96
- throw new Error(`config-center principal-caps HTTP ${res.status}`);
97
79
  // 边界读=schema(clay 宪法 [2704] §2,同源锚=registry-core PrincipalCapsWire):治理面
98
80
  // runtimeCaps/budget 严格+passthrough(值漂移整体拒 ⇒ 本函数 throw ⇒ resolver fail-close,
99
81
  // 「outage 绝不静默 GRANT」同向);[565]/[580] fail-open 面与 orgMemory(C3 失败域隔离)在
100
82
  // schema 里保持宽读,归一/校验仍在下方与目录腿(极性不因 schema 化翻转)。
101
- const parsedBody = PrincipalCapsWire.safeParse(await res.json());
83
+ const parsedBody = PrincipalCapsWire.safeParse(raw.json);
102
84
  if (!parsedBody.success) {
103
85
  throw new Error(`config-center principal-caps body failed schema validation: ${parsedBody.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 600)}`);
104
86
  }
105
87
  const body = parsedBody.data;
106
- // design/170 件A C8(响应 principal 回声核,串租户防线一行):center 两条 ?principal= 腿的 body 都
107
- // 回带 principal——在场且与请求不符 = 中间层/LB 把别人的 caps 视图递了过来,弃用整响应(throw ⇒
108
- // caps 腿 fail-close、org 目录腿记 unavailable)。键缺席 = 旧 center 形,按无证据容忍(回声核只拦
109
- // 「有回声且对不上」的确定性错递,不把旧形升级成故障)。
110
- if (body.principal !== undefined && body.principal !== principal) {
111
- throw new Error(`config-center principal-caps response echoes principal ${JSON.stringify(body.principal)} but ${JSON.stringify(principal)} was requested — discarding the whole response (cross-tenant delivery defense)`);
112
- }
88
+ assertPrincipalEcho(body.principal, principal);
113
89
  return {
114
90
  runtimeCaps: body.runtimeCaps ?? null,
115
91
  configured: Boolean(body.configured),
@@ -166,9 +142,82 @@ export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchI
166
142
  // org 目录腿据此判 section_absent 瞬时;形状校验是 org-memory-admission 模块用 PrincipalOrgMemoryWire
167
143
  // 的事,本腿保持薄透传不预判)。
168
144
  ...("orgMemory" in body ? { orgMemory: body.orgMemory } : {}),
169
- etag: res.headers.get("etag") ?? undefined,
145
+ etag: raw.etag, // 与提取共用件之前逐字等价:键恒在场,无 ETag 头时值为 undefined(消费端只读真值)
170
146
  };
171
147
  }
148
+ /** design/170 件A C8(响应 principal 回声核):center 两条 `?principal=` 腿的 body 都回带 principal——
149
+ * 在场且与请求不符 = 中间层/LB 把别的 principal 的视图递了过来,弃用整响应(throw ⇒ caps 腿
150
+ * fail-close、org 目录腿记 unavailable)。键缺席 = 旧 center 形,按无证据容忍(回声核只拦「有回声
151
+ * 且对不上」的确定性错递,不把旧形升级成故障)。两条腿共用同一道闸,不各写一遍。 */
152
+ function assertPrincipalEcho(echoed, principal) {
153
+ if (echoed !== undefined && echoed !== principal) {
154
+ throw new Error(`config-center principal-caps response echoes principal ${JSON.stringify(echoed)} but ${JSON.stringify(principal)} was requested — discarding the whole response`);
155
+ }
156
+ }
157
+ /** 两条 `?principal=` 腿共用的**传输**半场:scheme 闸 / `?principal=[&worker=]` / Bearer / 8s 超时 /
158
+ * 304 语义。逐字来自原 {@link fetchPrincipalCaps} 函数体,提成共用件是为了第二条腿(org 段读取)
159
+ * 不必抄一遍传输纪律——抄一遍就等于给同一条边界立两套会各自漂移的规矩。
160
+ * 返回 `null` = 合法的 304(仅当我们真的发了条件请求);否则是**解析前**的 body 原值。 */
161
+ async function requestPrincipalView(baseUrl, token, principal, etag, fetchImpl, worker) {
162
+ const scheme = new URL(baseUrl).protocol;
163
+ if (scheme !== "http:" && scheme !== "https:")
164
+ throw new Error(`SEMA_REGISTRY_URL must be http(s), got "${scheme}"`);
165
+ // `?principal=<id>[&worker=<self>]` — worker authorizes the wpt_<self> caps-only path; harmless on a full token.
166
+ const params = new URLSearchParams({ principal });
167
+ if (worker)
168
+ params.set("worker", worker);
169
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/config/effective?${params.toString()}`;
170
+ const res = await fetchImpl(url, {
171
+ headers: { authorization: `Bearer ${token}`, ...(etag ? { "if-none-match": etag } : {}) },
172
+ signal: AbortSignal.timeout(8000),
173
+ });
174
+ if (res.status === 304) {
175
+ // A 304 is only valid as a reply to OUR conditional. If we sent no etag and still got 304, center is
176
+ // misbehaving — DON'T treat it as "unchanged" (that surfaces undefined caps = no restriction = fail OPEN).
177
+ // Throw so the resolver fail-closes (review B). With an etag, 304 = genuine "unchanged" → null.
178
+ if (!etag)
179
+ throw new Error("config-center returned 304 to a non-conditional principal-caps request");
180
+ return null;
181
+ }
182
+ if (!res.ok)
183
+ throw new Error(`config-center principal-caps HTTP ${res.status}`);
184
+ const responseEtag = res.headers.get("etag");
185
+ return { json: await res.json(), ...(responseEtag !== null ? { etag: responseEtag } : {}) };
186
+ }
187
+ /** 件A C3 失败域隔离的读取投影:org 目录腿只消费两个字段——`principal`(回声核)与 `orgMemory` 段
188
+ * 原值。用 `PrincipalCapsWire.pick()` 取子集(schema 属主仍是 registry-core,这里不复制形状定义,
189
+ * `.passthrough()` 随 `_def` 一并继承)。 */
190
+ const PrincipalOrgMemoryEnvelope = PrincipalCapsWire.pick({ principal: true, orgMemory: true });
191
+ /**
192
+ * design/170 件A —— 读 `?principal=` 响应里的 org 记忆解析段(org 目录腿的取数口)。
193
+ *
194
+ * 🔬 **为什么不复用 {@link fetchPrincipalCaps}**(复审车 D-2 修):C3「两张表失败域互不干扰」是在档
195
+ * 裁定,registry-core 的 `PrincipalCapsWire` 也正是为此把 org 段留成 `unknown`(「在本 schema 里校验
196
+ * 会把 org 段坏形连坐进 caps 腿」)。但隔离此前只做了**单向**:org 段坏不连坐 caps,caps 段坏却整体
197
+ * 连坐 org —— 治理面 `runtimeCaps`/`budget` 的一次值漂移会让整封 safeParse 失败、函数 throw,于是一个
198
+ * **形状完全正确**的 org 授权段读不出来,目录记 `fetch_failed`、准入铸瞬时拒。治理面的 fail-closed
199
+ * 极性是对的(它管的是「别静默放松治理」),但它不该决定 org 授权面的可用性。
200
+ *
201
+ * 所以这条腿用 {@link PrincipalOrgMemoryEnvelope} 只读它真正消费的两个字段:治理面漂移在这里是被
202
+ * `.passthrough()` 原样放过的未知键,不构成本腿的故障。段本身的形状校验仍在目录腿
203
+ * (`PrincipalOrgMemoryWire`),本腿保持薄透传不预判。
204
+ *
205
+ * 返回值三值语义:段键缺席 ⇒ `undefined`(旧 center 能力握手 ⇒ 目录判 `section_absent` 瞬时);
206
+ * 段在场 ⇒ 原值(空表 = 取数成功的负结果,与缺席不同格)。条件请求不适用(本腿恒不带 etag),
207
+ * 因此 304 在传输层就是 fail-loud。
208
+ */
209
+ export async function fetchPrincipalOrgMemory(baseUrl, token, principal, fetchImpl = fetch, worker) {
210
+ const raw = await requestPrincipalView(baseUrl, token, principal, undefined, fetchImpl, worker);
211
+ // etag 恒缺席 ⇒ 304 已在传输层 throw;这一支只为类型收口(不可达)。
212
+ if (raw === null)
213
+ throw new Error("config-center returned 304 to a non-conditional org-directory fetch");
214
+ const parsed = PrincipalOrgMemoryEnvelope.safeParse(raw.json);
215
+ if (!parsed.success) {
216
+ throw new Error(`config-center org-memory section failed schema validation: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 600)}`);
217
+ }
218
+ assertPrincipalEcho(parsed.data.principal, principal);
219
+ return parsed.data.orgMemory;
220
+ }
172
221
  /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
173
222
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
174
223
  export class ConfigCenterHttpError extends Error {
@@ -717,14 +717,23 @@ export interface ServiceConfigFlat {
717
717
  * recommended set (not a merge); `off` or an empty value = disabled (empty array); unset = core's recommended
718
718
  * set. Deny composes deny-wins, so a session exemption / acceptEdits auto-allow never crosses it. */
719
719
  sensitiveWritePatterns: string[];
720
- /** [1557]§四 opt-in (cli[1555]② finding, core[1556] suggested mechanism): CC manual-family permission modes
721
- * (default/auto/acceptEdits) gate every fs WRITE hand tool (Write/Edit/NotebookEdit) but never touched Bash —
722
- * a model can `echo -n '…' > file` straight past the write-approval card. `MANUAL_MODE_SHELL_GATE` opts a
723
- * deployment into ALSO tightening `TaskSpec.shellGate` on those same modes (core `bashReversibilityProbe`
724
- * under `"classify"` only asks on constructively-irreversible commands — shell redirects etc.).
720
+ /** [1557]§四 opt-in (cli[1555]② finding, core[1556] suggested mechanism): the CC manual-family permission
721
+ * modes (default/auto/acceptEdits) gate every fs WRITE hand tool (Write/Edit/NotebookEdit) but never touched
722
+ * Bash — a model can `echo -n '…' > file` straight past the write-approval card. `MANUAL_MODE_SHELL_GATE`
723
+ * opts a deployment into ALSO tightening `TaskSpec.shellGate` (core `bashReversibilityProbe` under
724
+ * `"classify"` asks on constructively-irreversible commands — shell redirects etc. — **plus** the boundary
725
+ * case in the 🔴 note below; it is not a "constructive-irreversibility only" filter).
726
+ * 🔴 **施加点 = governance 层,无条件(#153 / [2713] 分单②;搬家前的旧描述已作废)**:与 `AUTONOMY`
727
+ * 同拍在 `applyRuntimeGovernance`(runtime-governance.ts)里施加,**不看客户端 settings/permissionMode
728
+ * 的在场性、不看落在哪个模式臂、不看执行 lane**。旧家挂在 task-settings 的 default/auto/acceptEdits 三臂
729
+ * + host-lane fs-write wiring 上,等于让一个部署级旋钮的生死由客户端表态决定(print/headless 腿不 stamp
730
+ * settings ⇒ 整块跳过;bypassPermissions 臂不回 shellGate;非 host lane wiring 不建 ⇒ 沙箱 lane 全失效)。
731
+ * rank 合成:与 autonomy 派生值取大(`ask`→`"always"` 压过 `"classify"`),对 base 只升不降。
732
+ * 客户端 settings 折叠在其后、tighten-only,拿不掉这个值;SUP 路由姿态只会把它抬到 `"always"`。
725
733
  * 🔴 **语义随 core 版本演进(2026-07-31 核)**:`"classify"` 下「纯读自动放行」这句自 core 2.7.0 起
726
734
  * **不再无条件成立** —— RB-412 给 classifier 加了 root 边界扫描,core 内部自铸 boundary
727
- * (`prepare-task.ts` 的 `shellReadBoundary`:roots=任务 root+additionalRoots,cwd=活 handsCwd),
735
+ * (`prepare-task.ts` 的 `shellReadBoundary`:roots=任务 root+additionalRoots+additionalReadRoots,
736
+ * cwd=活 handsCwd),
728
737
  * **读到 roots 之外的路径同样会 ask**(server 侧零接线即得:我们只传 shellGate,边界是 core 自算的)。
729
738
  * 旧 core(<2.7.0)下仍是名字级判定=越界读放行,两者都能工作、后者更严 ⇒ floor 不因此抬。
730
739
  * Absent/unset = `undefined` = **zero behavior change** (this is a tighten-ONLY opt-in, never a
package/dist/config.js CHANGED
@@ -911,8 +911,10 @@ function parseApprovalDomain(ctx) {
911
911
  return [];
912
912
  return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
913
913
  })(process.env.SENSITIVE_WRITE_PATTERNS),
914
- // [1557]§四: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays undefined
915
- // (no field on the config object at all), so the fs-write gate wiring never even considers it.
914
+ // [1557]§四 → #153: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays
915
+ // undefined (no field on the config object at all), so `applyRuntimeGovernance` never receives the knob and
916
+ // THIS knob contributes nothing to the governance pass (zero behavior change from it). Note that pass can
917
+ // still set `TaskSpec.shellGate` on its own account — `AUTONOMY=ask` derives "always" independently.
916
918
  ...(process.env.MANUAL_MODE_SHELL_GATE === "always" || process.env.MANUAL_MODE_SHELL_GATE === "classify"
917
919
  ? { manualModeShellGate: process.env.MANUAL_MODE_SHELL_GATE }
918
920
  : {}),
@@ -200,8 +200,10 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
200
200
  mcpElicitation: Boolean(deps.elicitation),
201
201
  // §4④: AskUserQuestion LIVE HITL — true ONLY when the onQuestion producing path is wired
202
202
  // (ASK_QUESTION_ENABLED). Same SCOPE as mcpElicitation (streaming + durable legs; a synchronous /v1/tasks or a
203
- // sub-task with no live stream headless-defaults — honest, not a hang). The durable-disconnected-human leg is
204
- // always available (createDurableQuestionPolicy on the durable path), independent of this live capability.
203
+ // sub-task with no live stream headless-defaults — honest, not a hang). #152: on a DURABLE deployment the
204
+ // disconnected-human leg (checkpoint park + /decide) is no longer unconditional — it is what a leg with NO
205
+ // reachable live face adjudicates to, while a live-stream leg asks the human over the stream instead. So this
206
+ // flag being true also means "a live leg's question does NOT park"; a leg with no live face still parks.
205
207
  askUserQuestion: Boolean(deps.question),
206
208
  // [816]/[820]②: live tool-approval HITL — true ONLY when the onAsk producing path is wired
207
209
  // (TOOL_APPROVAL_ENABLED). LIVE sync-stream leg only; since [879] G1 (OnAsk 三值化) every other leg
@@ -21,6 +21,37 @@ function sseData(res, payload) {
21
21
  const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
22
22
  res.write(`${idLine}data: ${JSON.stringify(payload)}\n\n`);
23
23
  }
24
+ /**
25
+ * 同步提交腿的**唯一**应答口:`retryAfterSec` 在体里 ⇒ 标准 `retry-after` 头同值在头里。
26
+ *
27
+ * 🔴 为什么必须是同一个口(复审车 C,2026-08-05):`memory.admission_required`(503)与
28
+ * `usage.window_exhausted`(429)是同族「等一会儿再来」,而后者在 `http/server.ts` 的 pre-admission 门里
29
+ * **头体双给**。本腿此前只给体——但**自动重试引擎读的是头**:SDK `Transport.request` 取
30
+ * `parseRetryAfter(res.headers.get("retry-after"))`,退避睡 `retryAfterMs ?? backoff(attempt)`,而 503 在
31
+ * `RETRYABLE_STATUS` 内。头缺席 ⇒ 引擎回落 250ms/500ms 通用退避,默认 `maxRetries: 2` 的三次尝试**全部**
32
+ * 落在 core 的不可用窗内(core 侧 `Math.max(backoffMs, 1000)`),提交方在 1 秒内烧光重试预算然后报错 ——
33
+ * 这个提示存在的唯一目的正好被绕过。头体同源(体有才给、值逐字相同)也让「体说等 11 秒、头说别的」不可能。
34
+ *
35
+ * 放在应答口而不是铸体处:同步腿的 4xx/5xx **不进** idem 缓存(谓词 `status < 400`),但并发同键的调用方会
36
+ * 被 `peek` 喂在飞 promise 的**同一份**结果 —— 那条腿不重跑铸体处的代码,只走这里。
37
+ */
38
+ function sendSubmitResult(res, resp) {
39
+ const body = resp.body;
40
+ // 两个判定各司其职,都不能省:`Object.hasOwn` 管**语义**——`in` 认原型链,而 `JSON.stringify` 只序列化
41
+ // 自有可枚举键,只用 `in` 就存在「头说等 11 秒、体里根本没这个键」的形(继承来的键足以点亮头);`in` 管
42
+ // **窄化**——本仓 lib 里的 `Object.hasOwn` 没有类型谓词重载,少了它就得写断言(边界禁项)。
43
+ // 🔴 诚实边界:这两个判定合起来**不等于**「JSON 里一定看得见」的通用证明(不可枚举自有键、数组上的具名键
44
+ // 都能同时通过而不被序列化)。本函数的真保证是**受控生产者**级的:本腿流经这里的应答体形状不止一种
45
+ // (200 的 `stripCheckpointToken(result)` 是引擎对象、409 是冲突材料……),而其中**唯一**带
46
+ // `retryAfterSec` 的是下面那个 memory.admission_required 臂里的对象字面量。写死这条而不是宣称一个更强的
47
+ // 不变式 —— 宣称过强正是「过期声明」的铸法。
48
+ if (typeof body === "object" && body !== null && Object.hasOwn(body, "retryAfterSec") && "retryAfterSec" in body) {
49
+ const sec = body.retryAfterSec;
50
+ if (typeof sec === "number" && Number.isFinite(sec))
51
+ res.setHeader("retry-after", String(sec));
52
+ }
53
+ sendJson(res, resp.status, resp.body);
54
+ }
24
55
  export async function handleTasks(req, res, url, ctx) {
25
56
  const miss = { fell: false };
26
57
  await handleTasksBody(req, res, url, ctx, miss);
@@ -70,7 +101,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
70
101
  }
71
102
  else {
72
103
  const resp = await cached;
73
- sendJson(res, resp.status, resp.body);
104
+ sendSubmitResult(res, resp); // 头体同源(见 sendSubmitResult 顶注:并发同键的这条腿不重跑铸体处)
74
105
  }
75
106
  return;
76
107
  }
@@ -746,8 +777,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
746
777
  const withElicit = deps.elicitation
747
778
  ? () => deps.elicitation.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal }, streamBody)
748
779
  : streamBody;
780
+ // #152 复审 A1:`deliverable` 是 durable 部署上 park-vs-live 判决的投递面谓词(见 question.ts 该键注)。
781
+ // 本腿的投递面就是这条 SSE 连接,而 ①b detach 车道下客户端断连**不 abort、不结束这条腿**——run 继续在
782
+ // 本 runWithContext 作用域里跑,emitAsk 往已死 socket 写被静默丢弃(question 帧无 ledgerSink 双写)。
783
+ // 谓词让判决面此时如实读作「无活流」⇒ durable park(运维可经 /decide 补答),而不是 allow 后挂满 ttl
784
+ // 以空答喂模型。断连发生在判决之后的窗口仍落 ttl 空答(与 elicit/approval 同栏,各自 TTL 语义)。
749
785
  const withQuestion = () => deps.question
750
- ? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal }, withElicit)
786
+ ? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal, deliverable: () => !res.writableEnded && !res.destroyed }, withElicit)
751
787
  : withElicit();
752
788
  // [816]/[820]②: the live tool-approval context — SAME id/owner/emit/signal as question/elicit
753
789
  // (nested ALS, each its own store). sessionId keys the "allow all edits this session" state.
@@ -1116,7 +1152,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1116
1152
  return { status: 200, body: stripCheckpointToken(result) };
1117
1153
  };
1118
1154
  const resp = await idemCache.run(idemKey, runSyncSubmitLeg, (r) => r.status < 400); // cache only success-range outcomes (a completed/failed BILLED result, replay to avoid double-bill); never a session-active 409 or a resume-at 4xx (billed nothing — a retry must re-check)
1119
- sendJson(res, resp.status, resp.body);
1155
+ sendSubmitResult(res, resp); // 体里有 retryAfterSec ⇒ 标准 retry-after 头同值(见 sendSubmitResult 顶注)
1120
1156
  }
1121
1157
  return;
1122
1158
  }
@@ -1396,10 +1396,16 @@ export function createHttpServer(rawDeps) {
1396
1396
  }
1397
1397
  // Durable-ask request/state coherence (TC-5.4), all pre-CAS so a mismatched request never consumes the
1398
1398
  // checkpoint:
1399
- // - approving a pending AskUserQuestion REQUIRES body.answer. Without it the resume re-mounts the
1400
- // QUESTION_AWAITS_RESUME placeholder, the pending call executes against it and throws QuestionConfigError
1401
- // the operator's question is unrecoverably LOST (checkpoint consumed, no answer ever delivered). Fail the
1402
- // request instead; the operator retries with the answer.
1399
+ // - approving a pending AskUserQuestion REQUIRES body.answer. Without it the redeemed call executes against
1400
+ // whatever question face the rebuilt spec has, and the operator's question is unrecoverably LOST (checkpoint
1401
+ // consumed, no answer ever delivered). Fail the request instead; the operator retries with the answer.
1402
+ // 🔴 #152: this check is now the ONLY one on this route. core has its own pre-CAS refusal for
1403
+ // "approve a content-ask with no answer" (runtask.js: `taskConfig.onQuestion ?? deps.onQuestion` is
1404
+ // `undefined` or QUESTION_AWAITS_RESUME), but on an ASK_QUESTION_ENABLED deployment the spec no longer
1405
+ // stamps the sentinel and `deps.onQuestion` is the coordinator, so core's condition is never met and it
1406
+ // does NOT refuse. Without the guard below the resume would execute the question on a leg with no live
1407
+ // context, take the coordinator's empty "no human" default, and consume the checkpoint. Do not delete it
1408
+ // on the belief that core still catches this (parked twin: src/parked-decide.ts).
1403
1409
  // - an answer is only meaningful on an approve of a question gate; on a deny or a non-question gate it
1404
1410
  // signals the operator is deciding a different pending action than the one parked — reject, don't guess.
1405
1411
  if (decision === "approve" && pendingTool === "AskUserQuestion" && !answer) {
@@ -445,6 +445,10 @@ export function createLeaderRunner(cfg) {
445
445
  if (cfg.durable) {
446
446
  const d = cfg.durable;
447
447
  durableSpec = {
448
+ // AskUserQuestion 判决:本腿是 resolve-spec `createDurableQuestionGate` 单一属主声明的**登记豁免**
449
+ // (复审车B 上抛/属主裁定 2026-08-05)——leader worker 无活体问答面可装(不是 HTTP 活流腿,
450
+ // QuestionCoordinator 的 ALS 永不覆盖到这里),core 原形+下方 sentinel 即完整正确语义,且 leader
451
+ // 子系统不 import boot 装配层(分层)。⚠️ 若 leader 腿将来接活体面,必须改调该工厂并撤本注。
448
452
  toolPolicy: combinePolicies(createDurableQuestionPolicy(), createDurableAskPolicy({ requireApproval: d.requireApproval, ...(d.deny ? { deny: d.deny } : {}), ...(d.autoBudget ? { autoBudget: d.autoBudget } : {}), ...(d.neverAuto ? { neverAuto: d.neverAuto } : {}) })),
449
453
  checkpointStore: d.checkpointStore,
450
454
  durableApproval: { scope: "_leader", ...(d.ttlMs ? { ttlMs: d.ttlMs } : {}) },
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createDurableQuestionPolicy, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend } from "@sema-agent/core";
4
+ import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend } from "@sema-agent/core";
5
5
  import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -37,7 +37,7 @@ import { createHookLlm } from "./hooks/hook-llm.js";
37
37
  import { startFleetClientFromEnv } from "./fleet-client.js";
38
38
  import { reclaimOrphansAtBoot } from "./boot-reclaim.js";
39
39
  import { createConfigCenterRuntime } from "./boot/config-center.js";
40
- import { createResolveSpec } from "./boot/resolve-spec.js";
40
+ import { createDurableQuestionGate, createResolveSpec } from "./boot/resolve-spec.js";
41
41
  import { startReapers } from "./boot/reapers.js";
42
42
  import { openStores } from "./boot/stores.js";
43
43
  import { createBudgetAndTracing } from "./boot/budget-tracing.js";
@@ -621,10 +621,13 @@ async function main() {
621
621
  // count=1,durableMandate=true(host 有 durableApproval 且 durable 车道无 live onAsk 的恒形)。
622
622
  // exempt 探针锚 = row.rootSessionId——与 parked decide 的 remember grant 锚同键([1591] 候裁②),
623
623
  // 重建链上的豁免语义与原链一致。重建不出(无 root 锚)= 不供席 = core pre-CAS 门诚实拒(现状)。
624
+ // #152 复审 A2:「同参重建」是本工厂自述的不变式,所以 question 门必须调 resolve-spec 的**同一个**
625
+ // 工厂并传同一个活体面,不能自折 core 原形——分腿改动只落一侧就是两份拷贝开始漂移的那一刻。
626
+ // 赎回腿今天无 question ALS ctx(/decide 路由触发,core driveParkedResume 内驱动)⇒ 判 ask,与原形同判。
624
627
  const parkedReviveInheritedGate = parkedReviveTool && config.durableApproval
625
628
  ? (row) => ({
626
629
  parentConstraints: [{
627
- policy: combinePolicies(createDurableQuestionPolicy(), createDurableAskPolicy({
630
+ policy: combinePolicies(createDurableQuestionGate(question), createDurableAskPolicy({
628
631
  requireApproval: config.approvalRequire, deny: config.approvalDeny, autoBudget: config.approvalAutoBudget, neverAuto: config.approvalNeverAuto,
629
632
  ...(approvalExemptionStore && row.rootSessionId
630
633
  ? {
@@ -78,7 +78,8 @@ export interface MemoryScopeAdmissionOptions {
78
78
  onOutcome?: (outcome: string, details?: Record<string, unknown>) => void;
79
79
  }
80
80
  /** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
81
- * memory.admission_required 终局码并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
81
+ * **瞬时**码 memory.admission_required(503,重试可改判——与终局的 memory.admission_denied 恰好相对)
82
+ * 并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
82
83
  export declare class OrgMemoryAdmissionRetryError extends Error {
83
84
  readonly retryAfterMs: number;
84
85
  constructor(message: string, retryAfterMs: number);
@@ -131,7 +131,8 @@ export function createOrgMemoryDirectory(opts) {
131
131
  };
132
132
  }
133
133
  /** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
134
- * memory.admission_required 终局码并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
134
+ * **瞬时**码 memory.admission_required(503,重试可改判——与终局的 memory.admission_denied 恰好相对)
135
+ * 并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
135
136
  export class OrgMemoryAdmissionRetryError extends Error {
136
137
  retryAfterMs;
137
138
  constructor(message, retryAfterMs) {
@@ -158,6 +159,11 @@ export function createMemoryScopeAdmission(directory, opts) {
158
159
  }
159
160
  };
160
161
  return async ({ principal, requested, requestedWriteScope }) => {
162
+ /** 复审 D-3(7.1.0 后修):归因面。判决记录若不带 principal,运维只看得到「某个 org scope 被拒」而
163
+ * 无从知道**谁**被拒——「per-principal 归因走日志」这句设计承诺(与 CHANGELOG 7.0.0 同款公开陈述)
164
+ * 此前在代码里没有落点。principal 缺席时**不**塞键:干净 admit 那一路必须保持 `details === undefined`
165
+ * (装配点据此判「无事发生不打日志」;塞个 `principal: undefined` 会把每个成功任务变成一行 warn)。 */
166
+ const attributed = (details) => principal === undefined ? details : { principal, ...details };
161
167
  const requestOriginRequested = requested.filter((r) => r.origin === "request");
162
168
  const writeIsRequestOrigin = requestedWriteScope?.origin === "request";
163
169
  const hasRequestOrigin = requestOriginRequested.length > 0 || writeIsRequestOrigin;
@@ -203,7 +209,7 @@ export function createMemoryScopeAdmission(directory, opts) {
203
209
  // audit 模式对「读面通过但 write 未显式授」的判决只记 would-narrow,返回未收窄的全额;
204
210
  // enforce 照常收窄。ok:true 分支的 writeScope 恒为 fullWriteScope 或 null(decide 只做这两值)。
205
211
  if (opts.mode === "audit" && decision.verdict.ok && decision.verdict.writeScope !== fullWriteScope) {
206
- emit("ok", { audited: true, wouldNarrowWriteScope: true });
212
+ emit("ok", attributed({ audited: true, wouldNarrowWriteScope: true }));
207
213
  return admitFull();
208
214
  }
209
215
  emit("ok");
@@ -212,10 +218,10 @@ export function createMemoryScopeAdmission(directory, opts) {
212
218
  if (opts.mode === "audit") {
213
219
  // Zero behavior change: a would-deny (terminal or transient) is overridden to a full, UNNARROWED admit —
214
220
  // the real decision is only ever recorded via onOutcome, never enforced.
215
- emit(decision.outcome, { ...decision.details, audited: true, wouldDeny: true, ...(decision.kind === "unavailable" ? { retryAfterMs: decision.retryAfterMs } : { reason: decision.reason }) });
221
+ emit(decision.outcome, attributed({ ...decision.details, audited: true, wouldDeny: true, ...(decision.kind === "unavailable" ? { retryAfterMs: decision.retryAfterMs } : { reason: decision.reason }) }));
216
222
  return admitFull();
217
223
  }
218
- emit(decision.outcome, decision.details);
224
+ emit(decision.outcome, attributed(decision.details));
219
225
  if (decision.kind === "deny")
220
226
  return { ok: false, reason: decision.reason };
221
227
  throw new OrgMemoryAdmissionRetryError(`org memory directory unavailable (${decision.lookupReason})`, decision.retryAfterMs);
@@ -79,9 +79,12 @@ export async function decideParkedAgent(deps, req) {
79
79
  const pendingTool = req.pendingAction?.toolName;
80
80
  if (pendingTool === "AskUserQuestion" && req.decision === "approve" && req.answer === undefined) {
81
81
  // RB-459(core 5.7.0,F1/F6 翻案后唯一剩下的 claim 前拒):问题门的 approve 必须携 answer——
82
- // parked 腿无 live answering face,core 侧同判(invalid_outcome field:"answer"),但那要消费 claim
83
- // 并空跑一次 revive;这里 claim 前拒是同一判据的免损前置,不是 server 另立规则。带 answer 的
82
+ // parked 腿无 live answering face,消费 claim 后只会空跑一次 revive;claim 前拒是免损前置。带 answer
84
83
  // approve 与任何 deny 都放行透传,core 是语义权威(header 逐字匹配等)。
84
+ // 🔴 #152:本判据**不再**有 core 侧同判兜底。core 的 pre-CAS 拒条件是「`taskConfig.onQuestion ??
85
+ // deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」;ASK_QUESTION_ENABLED 部署上 spec 不再 stamp
86
+ // 哨兵、deps.onQuestion 是 coordinator,条件恒不成立 ⇒ core 放行,赎回腿会拿 coordinator 的空答当人答
87
+ // 并消费掉 checkpoint。这一手现在是该腿唯一的拒绝点(任务级孪生:src/http/server.ts 的 decide 路由)。
85
88
  return { status: 400, body: { error: "pending action is AskUserQuestion — approve requires body.answer (the operator's answers[]); deny needs none", errorCode: "decide.parked_answer_required", taskId: match.handle } };
86
89
  }
87
90
  if (row.name === undefined) {
@@ -19,6 +19,14 @@ export interface QuestionRunContext {
19
19
  owner: string | null;
20
20
  emit: (frame: QuestionFrame) => void | Promise<void>;
21
21
  abortSignal?: AbortSignal;
22
+ /** OPTIONAL reachability predicate for this leg's delivery face, consulted by {@link QuestionCoordinator.hasLiveContext}
23
+ * (the park-vs-live adjudication probe), NOT by `question` itself. CONSTRAINT: a leg whose face can go away WHILE the
24
+ * leg keeps running MUST supply it — `/v1/tasks/stream` under `x-detach-on-disconnect: true` keeps executing inside
25
+ * this ALS scope after the client left, and `emit` there writes to a dead socket and is silently dropped (question
26
+ * frames have no durable ledger twin, unlike SendUserFile's ledgerSink). Without the predicate the adjudication would
27
+ * read "live" and hand the leg to a face nobody can reach. ABSENT ⇒ always reachable, which is exactly right for the
28
+ * background leg (emit appends to the durable events tail, readable whenever a client attaches). */
29
+ deliverable?: () => boolean;
22
30
  }
23
31
  export interface QuestionThrottle {
24
32
  /** Max concurrent in-flight questions per run leg (parallel tool calls can each ask). Breach ⇒ headless default. */
@@ -67,10 +75,14 @@ export declare class QuestionCoordinator {
67
75
  };
68
76
  /** Test/observability hook: number of currently-parked questions. */
69
77
  pendingCount(): number;
70
- /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
71
- * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
78
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条**投递得到人的**
79
+ * 活流腿上(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade、durable resume 驱动=false)。
72
80
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
73
- * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
81
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
82
+ * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
83
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
84
+ * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
85
+ * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
74
86
  hasLiveContext(): boolean;
75
87
  private countersFor;
76
88
  }
package/dist/question.js CHANGED
@@ -192,12 +192,26 @@ export class QuestionCoordinator {
192
192
  pendingCount() {
193
193
  return this.pending.size;
194
194
  }
195
- /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
196
- * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
195
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条**投递得到人的**
196
+ * 活流腿上(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade、durable resume 驱动=false)。
197
197
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
198
- * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
198
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
199
+ * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
200
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
201
+ * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
202
+ * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
199
203
  hasLiveContext() {
200
- return this.als.getStore() !== undefined;
204
+ const ctx = this.als.getStore();
205
+ if (ctx === undefined)
206
+ return false;
207
+ if (ctx.deliverable === undefined)
208
+ return true;
209
+ try {
210
+ return ctx.deliverable();
211
+ }
212
+ catch {
213
+ return false;
214
+ }
201
215
  }
202
216
  countersFor(taskId) {
203
217
  let rc = this.counters.get(taskId);
@@ -35,10 +35,17 @@ export type CommandRule = {
35
35
  * fail-open(那个教训的解=引用常量,不是折叠)。 */
36
36
  export declare function isDelegationToolName(name: string): boolean;
37
37
  /** The hands-read-only DELEGATION STRIP (the main.ts governed-spec site, extracted here so the actual filter —
38
- * not just the name predicate — is pinned by tests): core does not propagate `handsReadOnly` to a delegated
39
- * child, so a read-only (plan-mode) run must not mount a delegation tool at all — the child would get WRITABLE
40
- * hands, defeating the read-only contract. Identity (the same array) when nothing matches, so the caller's
41
- * spec-object churn stays minimal. */
38
+ * not just the name predicate — is pinned by tests): a read-only (plan-mode) run does not mount a delegation
39
+ * tool at all.
40
+ *
41
+ * 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
42
+ * (`dist/agents/subagent.js:1501` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
43
+ * 腿 `:405` 同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
44
+ * 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
45
+ * 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
46
+ * 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
47
+ *
48
+ * Identity (the same array) when nothing matches, so the caller's spec-object churn stays minimal. */
42
49
  export declare function stripDelegationTools<T extends {
43
50
  name: string;
44
51
  }>(tools: readonly T[]): T[];
@@ -32,10 +32,17 @@ export function isDelegationToolName(name) {
32
32
  return name === DEFAULT_SUBAGENT_TOOL_NAME;
33
33
  }
34
34
  /** The hands-read-only DELEGATION STRIP (the main.ts governed-spec site, extracted here so the actual filter —
35
- * not just the name predicate — is pinned by tests): core does not propagate `handsReadOnly` to a delegated
36
- * child, so a read-only (plan-mode) run must not mount a delegation tool at all — the child would get WRITABLE
37
- * hands, defeating the read-only contract. Identity (the same array) when nothing matches, so the caller's
38
- * spec-object churn stays minimal. */
35
+ * not just the name predicate — is pinned by tests): a read-only (plan-mode) run does not mount a delegation
36
+ * tool at all.
37
+ *
38
+ * 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
39
+ * (`dist/agents/subagent.js:1501` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
40
+ * 腿 `:405` 同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
41
+ * 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
42
+ * 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
43
+ * 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
44
+ *
45
+ * Identity (the same array) when nothing matches, so the caller's spec-object churn stays minimal. */
39
46
  export function stripDelegationTools(tools) {
40
47
  const kept = tools.filter((t) => !isDelegationToolName(t.name));
41
48
  return kept.length === tools.length ? tools : kept;
@@ -232,7 +239,11 @@ export function applyRuntimeGovernance(base, governance) {
232
239
  const candidate = overrides.shellGate !== undefined && SHELL_GATE_RANK[overrides.shellGate] >= SHELL_GATE_RANK[governance.manualModeShellGate]
233
240
  ? overrides.shellGate
234
241
  : governance.manualModeShellGate;
235
- // base 已更严(如 resume 重放时 base 带 always)⇒ 省略,让 base 原样保留(省略=行为等价,不 throw)
242
+ // base 已更严 ⇒ 省略,让 base 原样保留(省略=行为等价,不 throw)。当前**装配上不可达、纯防御**:
243
+ // resolveSpec 的 spec 字面量从不写 shellGate,governance 是本进程里第一个产它的人(SUP 路由姿态在其**后**
244
+ // 才叠),所以每次真实调用的 base.shellGate 都是缺席。若将来有人给 base 种上更严的值,这里省略而不是让
245
+ // tightenTaskSpec 因「override 更松」throw——candidate 已是 autonomy 派生值与旋钮的较大者,省略它不会
246
+ // 丢掉 autonomy 那一半(autonomy 只产 "always",即最高 rank,永远不会落进这一支)。
236
247
  if (SHELL_GATE_RANK[candidate] >= SHELL_GATE_RANK[base.shellGate ?? "off"])
237
248
  overrides.shellGate = candidate;
238
249
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",