@sema-agent/server 7.65.0 → 7.66.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.
@@ -36,7 +36,7 @@ export function createGovernanceSeams(ctx) {
36
36
  assertGovernanceAssemblable({
37
37
  lockedKeys,
38
38
  complianceDenies,
39
- deploymentMcpConfigured: (config.mcpServers?.length ?? 0) > 0,
39
+ deploymentMcpConfigured: (config.mcpServers?.servers.length ?? 0) > 0,
40
40
  webFetchMounted: config.requirePrincipal !== true,
41
41
  });
42
42
  const lockedConfig = config.lockedConfigKeys !== undefined && config.lockedConfigKeys.length > 0 ? { keys: config.lockedConfigKeys } : undefined;
@@ -24,7 +24,7 @@ import { buildMemoryRemoteLaneWarn, memoryEngineBackendFor, memoryEngineRemoteLa
24
24
  import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
25
25
  import { assertPermissionRuleApprovalSchema, assertPermissionRuleTicketSchema } from "../plugins/permission-rule-store-sql.js";
26
26
  import { assertApprovalAskRuleMaterialSchema } from "../plugins/approval-ask-store-sql.js";
27
- import { assertCheckpointBidiColumnSchema } from "../plugins/checkpoint-store-sql.js";
27
+ import { assertCheckpointColumnSchema } from "../plugins/checkpoint-store-sql.js";
28
28
  import { assertFileHistorySchema } from "../plugins/file-history-store-sql.js";
29
29
  import { resolveStreamApprovalGate } from "../tool-approval.js";
30
30
  import { buildMemoryWriteBoundaryAudit, effectiveMemoryPersistenceCapable } from "./memory-boundary.js";
@@ -56,9 +56,9 @@ export async function openStores(ctx) {
56
56
  else if (mysqlPool)
57
57
  await assertToolResultProvenanceSchema(async (sql) => ({ rows: (await mysqlPool.query(sql))[0] }), "tidb");
58
58
  if (pgPool)
59
- await assertCheckpointBidiColumnSchema(async (sql) => ({ rows: (await pgPool.query(sql)).rows }), "pg");
59
+ await assertCheckpointColumnSchema(async (sql) => ({ rows: (await pgPool.query(sql)).rows }), "pg");
60
60
  else if (mysqlPool)
61
- await assertCheckpointBidiColumnSchema(async (sql) => ({ rows: (await mysqlPool.query(sql))[0] }), "tidb");
61
+ await assertCheckpointColumnSchema(async (sql) => ({ rows: (await mysqlPool.query(sql))[0] }), "tidb");
62
62
  if (config.permissionRulesEnabled) {
63
63
  if (pgPool)
64
64
  await assertPermissionRuleApprovalSchema(async (sql) => ({ rows: (await pgPool.query(sql)).rows }), "pg");
@@ -37,7 +37,9 @@ export declare function pickHandsRunner(hands: ScenarioHands, pair: HandsRunnerP
37
37
  * core 手带 band 的工具名**全集** —— 「挂 executionEnvFactory 才出现、不挂就消失」的那一批。
38
38
  *
39
39
  * 两段来源,都不是拍脑袋:
40
- * · `HAND_TOOL_EFFECTS`(core 导出面,`tools/fs`)—— band 的 effect 表,11 名。
40
+ * · `handBandEffects()`(core 7.8.0 B1 起的导出面;此前是手抄表常量 `HAND_TOOL_EFFECTS`)—— band 的
41
+ * effect 表,由**静态工具目录**按 `mountedBy.includes("hands")` 派生。名册单源之后这张表不再是一张
42
+ * 手抄名表,加/退工具由目录一处说了算。
41
43
  * · 下方锚定补集 —— `createHandsToolkit` / 手带块还会铸但**不在** effect 表里的 5 名(实测差集,见
42
44
  * test/scenario-hands-lane.test.ts 的对表格:它用真 Runner 跑「有手 roster ∖ 无手 roster」,与本常量逐名对账,
43
45
  * core 加/改 band 成员时红,防漂移)。
@@ -1,4 +1,4 @@
1
- import { HAND_TOOL_EFFECTS, RETIRED_TOOL_NAMES, combinePolicies, createAllowDenyPolicy } from "@sema-agent/core";
1
+ import { handBandEffects, RETIRED_TOOL_NAMES, combinePolicies, createAllowDenyPolicy } from "@sema-agent/core";
2
2
  export function withoutExecutionEnv(deps) {
3
3
  const { executionEnvFactory: _handsDropped, ...rest } = deps;
4
4
  return rest;
@@ -12,13 +12,13 @@ export function pickHandsRunner(hands, pair) {
12
12
  }
13
13
  }
14
14
  export const HANDS_BAND_TOOL_NAMES = Object.freeze([
15
- ...Object.keys(HAND_TOOL_EFFECTS),
15
+ ...handBandEffects().keys(),
16
16
  "SendMessage",
17
17
  "AgentTranscript",
18
18
  "Monitor",
19
19
  "EnterWorktree",
20
20
  "ExitWorktree",
21
- ].filter((n) => !RETIRED_TOOL_NAMES.has(n)));
21
+ ].filter((n, i, all) => !RETIRED_TOOL_NAMES.has(n) && all.indexOf(n) === i));
22
22
  export const HANDS_DUAL_USE_TOOL_NAMES = Object.freeze(["TaskOutput", "TaskStop", "SendMessage", "AgentTranscript"]);
23
23
  export const HANDS_EXCLUSIVE_TOOL_NAMES = Object.freeze(HANDS_BAND_TOOL_NAMES.filter((n) => !HANDS_DUAL_USE_TOOL_NAMES.includes(n)));
24
24
  export function handsDenyPolicy() {
@@ -1,5 +1,5 @@
1
1
  import type { A2aServerSpec, McpServerSpec } from "@sema-agent/core";
2
- import type { ScopedA2aPeer, ScopedMcpServer } from "../config-types.js";
2
+ import type { CenterMcp, ScopedA2aPeer } from "../config-types.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
4
  import type { LoadedSkill } from "../capabilities/skills.js";
5
5
  import type { CenterA2aPeer, CenterMcpServer, CenterSkillManifest } from "./types.js";
@@ -26,10 +26,10 @@ diskCacheDir?: string): Promise<LoadedSkill[]>;
26
26
  */
27
27
  export declare function resolveMcpServers(mcp: {
28
28
  servers: CenterMcpServer[];
29
- }, logger?: Logger): ScopedMcpServer[];
29
+ }, logger?: Logger): CenterMcp;
30
30
  /** The MCP servers applicable to a scenario: tagged with it, or untagged (global). undefined when none.
31
31
  * [ref]:alias 窗撤销,单键命中(同 `skillsForScenario`)。 */
32
- export declare function mcpForScenario(servers: ScopedMcpServer[] | undefined, scenario: string): McpServerSpec[] | undefined;
32
+ export declare function mcpForScenario(center: CenterMcp | undefined, scenario: string): McpServerSpec[] | undefined;
33
33
  /**
34
34
  * DESIGN-269 §2.1 —— resolve center A2A peers (`/effective.a2a`) into core `A2aServerSpec`s + the scenarios
35
35
  * each applies to. Exact sibling of {@link resolveMcpServers}, same three rules:
@@ -1,4 +1,5 @@
1
1
  import { readMcpSourceLabel } from "../mcp-source-label.js";
2
+ import { readMcpToolFaces } from "../mcp-tool-faces.js";
2
3
  import { skillContentHash } from "@sema-agent/settings-schema";
3
4
  import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "../mcp-content-origin.js";
4
5
  import { fetchSkillContent } from "./http-client.js";
@@ -79,6 +80,7 @@ function resolveRefs(refs, server, kind, logger, event = "sema_registry_mcp_env_
79
80
  }
80
81
  export function resolveMcpServers(mcp, logger) {
81
82
  const out = [];
83
+ const reservedNames = (mcp.servers ?? []).map((s) => s.name);
82
84
  for (const s of mcp.servers ?? []) {
83
85
  if (s.enabled === false)
84
86
  continue;
@@ -97,13 +99,23 @@ export function resolveMcpServers(mcp, logger) {
97
99
  });
98
100
  }
99
101
  const source = readMcpSourceLabel(s.name, s.source);
102
+ const facesRead = readMcpToolFaces(s.toolFaces);
103
+ if (!facesRead.ok) {
104
+ logger?.warn("sema_registry_mcp_tool_faces_invalid", {
105
+ server: s.name,
106
+ problem: facesRead.problem,
107
+ note: "server SKIPPED — a toolFaces overlay is a NARROWING declaration (its pathTarget is what the sensitive-path / write fences read); mounting the server without it would silently drop the protection you declared. Fix the entry and republish.",
108
+ });
109
+ continue;
110
+ }
111
+ const toolFaces = facesRead.faces === undefined ? {} : { toolFaces: facesRead.faces };
100
112
  if (s.transport.kind === "stdio") {
101
113
  const env = resolveRefs(s.transport.envRefs, s.name, "env", logger);
102
114
  if (env === null)
103
115
  continue;
104
116
  out.push({
105
117
  scenarios: s.scenarios ?? [],
106
- spec: { name: s.name, transport: { kind: "stdio", command: s.transport.command, args: s.transport.args, ...(Object.keys(env).length ? { env } : {}) }, ...allow, ...elicit, ...contentOrigin, ...source },
118
+ spec: { name: s.name, transport: { kind: "stdio", command: s.transport.command, args: s.transport.args, ...(Object.keys(env).length ? { env } : {}) }, ...allow, ...elicit, ...contentOrigin, ...source, ...toolFaces },
107
119
  });
108
120
  }
109
121
  else {
@@ -112,15 +124,16 @@ export function resolveMcpServers(mcp, logger) {
112
124
  continue;
113
125
  out.push({
114
126
  scenarios: s.scenarios ?? [],
115
- spec: { name: s.name, transport: { kind: "http", url: s.transport.url, ...(Object.keys(headers).length ? { headers } : {}), ...(s.transport.principalHeader ? { principalHeader: s.transport.principalHeader } : {}) }, ...allow, ...elicit, ...contentOrigin, ...source },
127
+ spec: { name: s.name, transport: { kind: "http", url: s.transport.url, ...(Object.keys(headers).length ? { headers } : {}), ...(s.transport.principalHeader ? { principalHeader: s.transport.principalHeader } : {}) }, ...allow, ...elicit, ...contentOrigin, ...source, ...toolFaces },
116
128
  });
117
129
  }
118
130
  }
119
131
  if (out.length > 0)
120
132
  logger?.info("sema_registry_mcp", { servers: out.map((s) => s.spec.name) });
121
- return out;
133
+ return { servers: out, reservedNames };
122
134
  }
123
- export function mcpForScenario(servers, scenario) {
135
+ export function mcpForScenario(center, scenario) {
136
+ const servers = center?.servers;
124
137
  if (!servers || servers.length === 0)
125
138
  return undefined;
126
139
  const hit = servers.filter((s) => s.scenarios.length === 0 || s.scenarios.includes(scenario)).map((s) => s.spec);
@@ -116,6 +116,17 @@ export interface CenterMcpServer {
116
116
  /** B-036 / S-143:声明方自述的分组标签(开集,非空 ≤80;settings-schema 1.7.0 `McpServerSpec.source`)——上 core
117
117
  * `McpServerSpec.source`,由 `wiring_manifest.mcp[].source` 逐字回显。显示用,不进信任轴。 */
118
118
  source?: string;
119
+ /**
120
+ * 件8(core 7.8.0 [ref] §2.7;settings-schema 1.9.0 `McpServerSpec.toolFaces`)—— 这台 server 的
121
+ * **per-tool 面覆盖层**,键 = server 的**原始(未加命名空间)**工具名,值 = `{ family?, pathTarget?,
122
+ * renderHints?, ruleFace? }`。上 core `McpServerSpec.toolFaces`。
123
+ *
124
+ * 🔴 类型是 `unknown` 而不是那个结构,**刻意的**:本类型是两条 center 腿的**承载形**(本地 config.d 经
125
+ * settings-schema 的 `z.unknown()` 解析透明地过来、远端 /effective 是直读 JSON),判据与坏形处置的单一
126
+ * 属主是 `mcp-tool-faces.ts` 的 `readMcpToolFaces`(`resolveMcpServers` 在铸 spec 那一刻调它)。在承载
127
+ * 形上先写一个结构,等于把同一张判据表抄成两份(而其中一份没有执行力)。
128
+ */
129
+ toolFaces?: unknown;
119
130
  transport: {
120
131
  kind: "stdio";
121
132
  command: string;
@@ -144,6 +144,7 @@ export function mapToServiceEffective(eff, version) {
144
144
  ...(s.elicitation !== undefined ? { elicitation: s.elicitation } : {}),
145
145
  ...(s.contentOrigin !== undefined ? { contentOrigin: s.contentOrigin } : {}),
146
146
  ...readMcpSourceLabel(s.name, s.source),
147
+ ...(s.toolFaces !== undefined ? { toolFaces: s.toolFaces } : {}),
147
148
  };
148
149
  if (s.transport.kind === "stdio") {
149
150
  return {
@@ -84,6 +84,15 @@ export interface ScopedMcpServer {
84
84
  scenarios: string[];
85
85
  spec: McpServerSpec;
86
86
  }
87
+ /** The deployment's MCP declaration, resolved (`resolveMcpServers`). `servers` = what actually MOUNTED (env-NAME refs
88
+ * resolved, `enabled:false` / missing-ref / malformed-`toolFaces` entries skipped, each with its own loud warn);
89
+ * `reservedNames` = EVERY name the deployment declared, mounted or not. A name the deployment declared is CLOSED to
90
+ * request-side injection (`resolveRequestMcp`) whether or not it mounted — otherwise "skipped" would silently mean
91
+ * "free for the caller to refill without the operator's `toolFaces` / `allowTools`" (车CF codex r2 [high]). */
92
+ export interface CenterMcp {
93
+ servers: ScopedMcpServer[];
94
+ reservedNames: readonly string[];
95
+ }
87
96
  /** DESIGN-269 §2.1 —— an A2A PEER resolved to a core spec (header-NAME refs already → real values) plus
88
97
  * the scenarios it applies to (empty = all). Exact sibling of {@link ScopedMcpServer}: `resolveSpec`
89
98
  * filters by scenario (`a2aForScenario`) and passes `spec` to core as `TaskSpec.a2a`.
@@ -687,8 +696,9 @@ export interface ServiceConfigFlat {
687
696
  };
688
697
  /** MCP servers from sema-registry (`/effective.mcp`), resolved at startup (env-NAME refs → values) and
689
698
  * filtered per scenario in `resolveSpec`. Not env-derived — populated by the sema-registry adapter when
690
- * present (restart-to-apply, like models/roles); undefined = no center MCP (scenarios use no MCP). */
691
- mcpServers?: ScopedMcpServer[];
699
+ * present (restart-to-apply, like models/roles); undefined = no center MCP (scenarios use no MCP).
700
+ * Carries both the mounted list and the full declared-name set — see {@link CenterMcp}. */
701
+ mcpServers?: CenterMcp;
692
702
  /** DESIGN-269 §2.1 —— A2A peers from the config center (`/effective.a2a`), resolved at startup
693
703
  * (header-NAME refs → values) and filtered per scenario in `resolveSpec` (`a2aForScenario`). Same
694
704
  * posture as {@link mcpServers}: not env-derived (no A2A_* env leg — a peer is a structured record,
@@ -1,4 +1,6 @@
1
+ import { screenedGate } from "../../trace/project.js";
1
2
  import { personDecision } from "../../host-decision.js";
3
+ import { sameExecutionRecord } from "@sema-agent/core";
2
4
  import { DECIDED_REPLAY_SCAN_CAP, approvalPayloadFingerprint } from "../../plugins/checkpoint-store-sql.js";
3
5
  import { APPROVAL_GATE_KINDS } from "../../tool-approval.js";
4
6
  import { isParkedRunStatus } from "../../plugins/store-contracts.js";
@@ -71,6 +73,21 @@ async function decideIdempotentReplay(cs, args) {
71
73
  if (row.payloadFp === null || row.payloadFp !== requestFp)
72
74
  return undefined;
73
75
  }
76
+ const executionOutcome = (() => {
77
+ const records = rows.map((r) => r.executionOutcome);
78
+ const first = records[0];
79
+ if (first === null || first === undefined)
80
+ return undefined;
81
+ for (const r of records) {
82
+ if (r === null)
83
+ return undefined;
84
+ if (!sameExecutionRecord(r, first)) {
85
+ warn("decide_replay_execution_outcome_divergent", { sessionId, rows: rows.length });
86
+ return undefined;
87
+ }
88
+ }
89
+ return screenedGate(first);
90
+ })();
74
91
  return {
75
92
  status: 200,
76
93
  body: {
@@ -78,6 +95,7 @@ async function decideIdempotentReplay(cs, args) {
78
95
  idempotent: true,
79
96
  decision,
80
97
  bindingEnforced: true,
98
+ ...(executionOutcome !== undefined ? { executionOutcome } : {}),
81
99
  },
82
100
  };
83
101
  }
@@ -12,7 +12,7 @@ import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../..
12
12
  import { buildCancelContext } from "../../run-cancel-context.js";
13
13
  import { recordResourceWindow } from "../../resource-window.js";
14
14
  import { redactSecrets } from "../../trace/redact.js";
15
- import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
15
+ import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, toolRosterDeltaEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
16
16
  import { cascadeConfig, runMeta } from "../run-meta.js";
17
17
  import { scopedIdempotencyKey } from "../idempotency.js";
18
18
  import { sendJson, sendError, sseHeaders } from "../send.js";
@@ -484,6 +484,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
484
484
  : wiringManifestEventData(ev)),
485
485
  });
486
486
  }
487
+ else if (ev.type === "tool_roster_delta") {
488
+ const rosterDelta = toolRosterDeltaEventData(ev);
489
+ if (rosterDelta !== undefined)
490
+ sseData(res, { type: "tool_roster_delta", ...rosterDelta });
491
+ }
487
492
  else if (ev.type === "human_input") {
488
493
  sseData(res, { type: "human_input", ...humanInputEventData(ev) });
489
494
  }
@@ -125,7 +125,7 @@ function mentionableNames(config, catalog) {
125
125
  }
126
126
  function isCheckpointReopenedFailure(r) {
127
127
  const code = failureCodeOf(r.terminal);
128
- return code === "resume.env_failed" || code === "resume.tool_unavailable";
128
+ return code === "resume.env_failed" || code === "resume.tool_unavailable" || code === "resume.tool_contract_mismatch";
129
129
  }
130
130
  function nestedParkStatusOf(r) {
131
131
  const paused = nestedPauseOf(r.terminal);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `McpServerSpec.toolFaces` —— MCP 工具的**面覆盖层**(core 7.8.0 [ref] §1.1/§2.7;声明面
3
+ * settings-schema 1.9.0)。一条规则、三条腿共用(请求腿 `task-mcp.ts normalizeServer` / center 腿
4
+ * `config-center/skills-mcp.ts resolveMcpServers` / 本地 config.d 腿 `config-provider.ts` 只做承载),
5
+ * 与 {@link import("./mcp-source-label.js").readMcpSourceLabel} 同一个「判据单点、三腿一致」的形。
6
+ *
7
+ * ## 分工(为什么本层**不**复述 core 的那张表)
8
+ * 覆盖层的**语义**校验属 core 的摄入侧,而且只有它做得了:`pathTarget.param` 是不是这只工具**物化后
9
+ * schema** 的顶层属性、审批卡要的键在不在、`ruleFace` 指的是不是标量键 —— 三件都要看远端 server 真发
10
+ * 回来的 schema,本层手里根本没有。core 的处置也已经定死:不合 ⇒ **这只工具在这条腿上不挂**,并通告
11
+ * 一次 `config.tool_face_invalid`(亲读 `dist/core/runner/tool-face-overlay.js` 的 `return undefined` 臂 ——
12
+ * 该文件 d.ts 头注里那句「the tool mounts BARE」是**过期**的,以实现为准)。
13
+ *
14
+ * 本层因此只做 core 的校验器**假定成立**的那一件事:**JSON 结构合不合 TS 型**。理由是硬的 —— core 的
15
+ * `toolFaceProblem` 是按 TS 型写的,`renderHints.summaryParams` 是个字符串时它会 `.find is not a function`
16
+ * 地把 prepare 整条炸掉,`ruleFace.params` 是个数字时 `for...of` 直接抛。原样透传一份任意 JSON 不是
17
+ * 「解析透明」,是把类型错误变成运行期崩溃。
18
+ *
19
+ * 闭词(`family` / `pathTarget.access` / `renderHints.approvalCard`)判据**取自 core 的导出常量**
20
+ * (`TOOL_FAMILIES` / `TOOL_PATH_ACCESSES` / `TOOL_APPROVAL_CARDS`),本仓一个词都不枚举 —— 与
21
+ * `MCP_DELIVERY_VERDICTS` 的消费姿势同款:词表单一属主在 core,抄一遍就是抄了一张会漂的表。
22
+ *
23
+ * ## 坏形的方向:**整条 server 不进**(fail-closed)
24
+ * 🔴 这一条被 codex 对抗复审 r1 的 [high] **翻正过一次**,把翻正的理由留在这里,因为它是本模块最容易
25
+ * 想错的一格:初版按 `contentOrigin` 的先例判「丢声明、留 server + 留痕」,而那条先例管的是**放宽**键
26
+ * (丢它 ⇒ 回落到过度标记 = fail-closed 的那一侧)。`toolFaces` 是**收窄**声明:它声明的 `pathTarget`
27
+ * 正是敏感路径策略、写围栏、编辑跟踪读的那一格 —— 丢掉它,那只工具就**无面挂载**,运维声明的保护
28
+ * 静默失效。codex 实测(同一只 MCP 写工具、同一个 `/work/.env` 目标,仅把 `renderHints.summaryParams`
29
+ * 从数组写成字符串):`createSensitivePathPolicy` 从 deny 变成 allow。一条 warn 补偿不了这个方向。
30
+ *
31
+ * 所以判据回到本仓对**收窄键**已经成文的那一条(`task-mcp.ts` 的 `allowTools`,[ref] / [ref] codex F1):
32
+ * **一个键在场就必须合法,否则这条 server 整条不进**。三问逐条:
33
+ * ① 谁需要 —— 在部署侧给 MCP 工具声明写围栏 / 审批卡 / 显示名的运维;
34
+ * ② 谁受伤 —— 覆盖层写错的那台 server:它**整条**不挂(请求腿进 `dropped` 并点名上报,center 腿跳过
35
+ * 该条、兄弟 server 照挂)。代价是真的(少一套工具),但它**响亮且立刻**;反方向的代价是一条
36
+ * **静默失效的安全声明**,而后者按 [ref] 不许存在于安全轴上;
37
+ * ③ 拿什么补偿 —— 报出来的名字本身:请求腿 `mcp_injection_dropped{dropped:[name]}`、center 腿
38
+ * `sema_registry_mcp_tool_faces_invalid{server,problem,note}` 一条点名 warn(`problem` 里带工具名与哪一段不合;运维照着改那一行即可)。
39
+ * 每只工具的面是**整只**判(core 明写 "applied whole or not at all");而**任一只**判不出形 ⇒ 整条 server
40
+ * 不进 —— 「留下好的那几只」同样是让坏的那一只无面挂载,与整张丢没有区别。
41
+ */
42
+ import { type McpServerSpec } from "@sema-agent/core";
43
+ type ToolFaces = NonNullable<McpServerSpec["toolFaces"]>;
44
+ /**
45
+ * 一条 server 条目的 `toolFaces` 判形结果:
46
+ * · `{ ok: true, faces }` —— 合形(`faces` 缺席 = 这台 server 没有覆盖层,或声明了一个**空**覆盖层:
47
+ * 两者对 core 同义,都不铸键);
48
+ * · `{ ok: false, problem }` —— 判不出形,**整条 server 不进**(方向与理由见模块头注),`problem` 是
49
+ * 给运维的一句点名(只带工具名与哪一段不合,不带面的值)。
50
+ */
51
+ export type McpToolFacesRead = {
52
+ ok: true;
53
+ faces?: ToolFaces;
54
+ } | {
55
+ ok: false;
56
+ problem: string;
57
+ };
58
+ /**
59
+ * 从任一腿的原始条目里读出 `toolFaces` —— **三条腿共用的唯一判据点**。
60
+ * 缺席 ⇒ 合形无覆盖层;整只不是对象 / **任一只**工具的面判不出形 ⇒ `ok:false`(调用方整条 server 不收)。
61
+ */
62
+ export declare function readMcpToolFaces(v: unknown): McpToolFacesRead;
63
+ export {};
64
+ //# sourceMappingURL=mcp-tool-faces.d.ts.map
@@ -0,0 +1,93 @@
1
+ import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ACCESSES } from "@sema-agent/core";
2
+ function isRecord(v) {
3
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4
+ }
5
+ function isStringArray(v) {
6
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
7
+ }
8
+ function memberOf(table, v) {
9
+ return typeof v === "string" && table.includes(v);
10
+ }
11
+ function onlyKeys(rec, allowed) {
12
+ return Object.keys(rec).every((k) => allowed.includes(k));
13
+ }
14
+ function readOneFace(raw) {
15
+ if (!isRecord(raw))
16
+ return undefined;
17
+ if (!onlyKeys(raw, ["family", "pathTarget", "renderHints", "ruleFace"]))
18
+ return undefined;
19
+ const out = {};
20
+ if (raw.family !== undefined) {
21
+ if (!memberOf(TOOL_FAMILIES, raw.family))
22
+ return undefined;
23
+ out.family = raw.family;
24
+ }
25
+ if (raw.pathTarget !== undefined) {
26
+ const pt = raw.pathTarget;
27
+ if (!isRecord(pt) || typeof pt.param !== "string" || !memberOf(TOOL_PATH_ACCESSES, pt.access))
28
+ return undefined;
29
+ if (!onlyKeys(pt, ["param", "access", "aliases", "skillScopeEligible"]))
30
+ return undefined;
31
+ if (pt.aliases !== undefined && !isStringArray(pt.aliases))
32
+ return undefined;
33
+ if (pt.skillScopeEligible !== undefined && typeof pt.skillScopeEligible !== "boolean")
34
+ return undefined;
35
+ out.pathTarget = {
36
+ param: pt.param,
37
+ access: pt.access,
38
+ ...(pt.aliases !== undefined ? { aliases: pt.aliases } : {}),
39
+ ...(pt.skillScopeEligible === true ? { skillScopeEligible: true } : {}),
40
+ };
41
+ }
42
+ if (raw.renderHints !== undefined) {
43
+ const rh = raw.renderHints;
44
+ if (!isRecord(rh) || !onlyKeys(rh, ["userFacingName", "activity", "summaryParams", "resultCards", "approvalCard"]))
45
+ return undefined;
46
+ if (rh.userFacingName !== undefined && typeof rh.userFacingName !== "string")
47
+ return undefined;
48
+ if (rh.activity !== undefined && typeof rh.activity !== "string")
49
+ return undefined;
50
+ if (rh.summaryParams !== undefined && !isStringArray(rh.summaryParams))
51
+ return undefined;
52
+ if (rh.resultCards !== undefined && !isStringArray(rh.resultCards))
53
+ return undefined;
54
+ if (rh.approvalCard !== undefined && !memberOf(TOOL_APPROVAL_CARDS, rh.approvalCard))
55
+ return undefined;
56
+ out.renderHints = {
57
+ ...(rh.userFacingName !== undefined ? { userFacingName: rh.userFacingName } : {}),
58
+ ...(rh.activity !== undefined ? { activity: rh.activity } : {}),
59
+ ...(rh.summaryParams !== undefined ? { summaryParams: rh.summaryParams } : {}),
60
+ ...(rh.resultCards !== undefined ? { resultCards: rh.resultCards } : {}),
61
+ ...(rh.approvalCard !== undefined ? { approvalCard: rh.approvalCard } : {}),
62
+ };
63
+ }
64
+ if (raw.ruleFace !== undefined) {
65
+ const rf = raw.ruleFace;
66
+ if (!isRecord(rf) || !onlyKeys(rf, ["primaryParams", "params"]))
67
+ return undefined;
68
+ if (rf.primaryParams !== undefined && !isStringArray(rf.primaryParams))
69
+ return undefined;
70
+ if (rf.params !== undefined && !isStringArray(rf.params))
71
+ return undefined;
72
+ out.ruleFace = {
73
+ ...(rf.primaryParams !== undefined ? { primaryParams: rf.primaryParams } : {}),
74
+ ...(rf.params !== undefined ? { params: rf.params } : {}),
75
+ };
76
+ }
77
+ return out;
78
+ }
79
+ export function readMcpToolFaces(v) {
80
+ if (v === undefined)
81
+ return { ok: true };
82
+ if (!isRecord(v))
83
+ return { ok: false, problem: `toolFaces is ${Array.isArray(v) ? "an array" : typeof v}, not an object of per-tool faces` };
84
+ const faces = {};
85
+ for (const [tool, raw] of Object.entries(v)) {
86
+ const face = readOneFace(raw);
87
+ if (face === undefined)
88
+ return { ok: false, problem: `the declared face for tool ${JSON.stringify(tool)} does not fit the tool-face shape` };
89
+ faces[tool] = face;
90
+ }
91
+ return Object.keys(faces).length > 0 ? { ok: true, faces } : { ok: true };
92
+ }
93
+ //# sourceMappingURL=mcp-tool-faces.js.map
@@ -17,6 +17,10 @@ export declare const FAIL_OPEN_TAGS: {
17
17
  readonly cls: "F";
18
18
  readonly note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。";
19
19
  };
20
+ readonly "server.trace.tool-roster-malformed": {
21
+ readonly cls: "F";
22
+ readonly note: "⑪(core 7.8.0 design/388,`trace/project.ts` 的 `wiring_manifest.tools` 段与 `tool_roster_delta` 构造器共用):载荷过不了 core 自己的 typebox 判形器(`ToolRoster` / `ToolRosterDelta`)⇒ 那一段缺席 / 那一帧不发,**绝不半张脸上 wire**。放行的最坏后果=消费端这条腿看不到工具名册(壳退回按名字分组、用通用审批卡),不影响任何裁决——名册是观测/渲染面,门在引擎里早已判完。反方向(把一份判不了形的名册照发)更坏:消费端会把读不出的行当成「这只工具没挂」。计数让「某个世代的产生者一直在发坏名册」显形。";
23
+ };
20
24
  readonly "server.task-mcp.source-label-dropped": {
21
25
  readonly cls: "F";
22
26
  readonly note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。";
@@ -8,6 +8,10 @@ export const FAIL_OPEN_TAGS = {
8
8
  cls: "F",
9
9
  note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。",
10
10
  },
11
+ "server.trace.tool-roster-malformed": {
12
+ cls: "F",
13
+ note: "⑪(core 7.8.0 design/388,`trace/project.ts` 的 `wiring_manifest.tools` 段与 `tool_roster_delta` 构造器共用):载荷过不了 core 自己的 typebox 判形器(`ToolRoster` / `ToolRosterDelta`)⇒ 那一段缺席 / 那一帧不发,**绝不半张脸上 wire**。放行的最坏后果=消费端这条腿看不到工具名册(壳退回按名字分组、用通用审批卡),不影响任何裁决——名册是观测/渲染面,门在引擎里早已判完。反方向(把一份判不了形的名册照发)更坏:消费端会把读不出的行当成「这只工具没挂」。计数让「某个世代的产生者一直在发坏名册」显形。",
14
+ },
11
15
  "server.task-mcp.source-label-dropped": {
12
16
  cls: "F",
13
17
  note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。",
@@ -1,6 +1,6 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
3
+ import { type ExecutionOutcomeRecordWord, type GateOutcome, type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
4
4
  import { type RuleOfferProjection } from "../approval-card.js";
5
5
  import { type SqlDialect, type SqlDriver } from "./sql-driver.js";
6
6
  export { approvalPayloadFingerprint } from "./approval-payload-fingerprint.js";
@@ -195,16 +195,31 @@ export declare function pendingRowPrecedes(a: {
195
195
  createdAt: number;
196
196
  token: string;
197
197
  }, pick?: "newest" | "oldest"): boolean;
198
+ /**
199
+ * `checkpoint` 表上**本仓后加、且被无条件引用**的列册 —— 升级前置探针({@link assertCheckpointColumnSchema})
200
+ * 的唯一数据源。一行一列:列名 + 这一列不在时**运行期**先坏在哪(拒启文案逐字用它,所以运维读到的是
201
+ * 「我会先在哪里挂」,不是一句泛泛的 schema 不齐)。
202
+ *
203
+ * 🔴 为什么是一张表而不是每列一个 `assert*ColumnSchema` 函数(S-160 归一):[ref]① 那次是一列一函数,
204
+ * 本批 [ref] 又要加两列 —— 照抄就是第二、第三份三十行的同形代码,而它们**唯一的差别只有列名与那句话**。
205
+ * 探测法(恒空 `WHERE 1=0` 读 + 方言缺列码)、失败方向(只在**可证**缺列时才给删表指路)、恢复动作
206
+ * (DROP TABLE checkpoint 的代价交代)三者对整张表是同一条,所以规则只留一条,列变成数据:下一次加列
207
+ * 只加一行,不再多一条规则。
208
+ */
209
+ export declare const CHECKPOINT_ADDED_COLUMNS: ReadonlyArray<{
210
+ column: string;
211
+ breaksAt: string;
212
+ }>;
198
213
  /**
199
214
  * [ref]① 升级前置断言 —— **拒启**,不是 warn(形照 [ref] `assertApprovalAskRuleMaterialSchema` / [ref] /
200
215
  * [ref] 三条先例;codex 店面轮 R1-[high] 采纳 —— 其引证的「MIGRATION.md 常设策略」经亲验不存在,采纳的
201
- * 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。
216
+ * 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。S-160 起判据对 {@link CHECKPOINT_ADDED_COLUMNS}
217
+ * **整张列册**跑(见那张表的头注:归一的理由)。
202
218
  *
203
219
  * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。一台**没删表**就升上来的
204
- * 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 本批新增的 `has_bidi_controls`
205
- * 列不在,而 `put()` 的 INSERT 与 `listPending` 的 SELECT **无条件**引用它。没有这道断言,服务照常过
206
- * 启动与健康检查,直到:每一次 durable park 落行在 `put()` 撞 unknown column(挂起面整条失效),
207
- * `GET /v1/approvals` 与 `/stream` 的每一次轮询 500 —— 运行期才响,且首撞点在用户的审批路径上。
220
+ * 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 后加的列不在,而写口与读口
221
+ * **无条件**引用它们。没有这道断言,服务照常过启动与健康检查,直到运行期才响,且首撞点在用户的
222
+ * 审批 / resume 路径上。
208
223
  *
209
224
  * 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**({@link isMissingColumnError},
210
225
  * 判据属主 `sql-errors.ts`);其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的「去 DROP TABLE」
@@ -214,7 +229,7 @@ export declare function pendingRowPrecedes(a: {
214
229
  * store 装配段、路由装配之前;**无条件**跑(与 [ref] tool_result 探针同档)——checkpoint 表在两方言的
215
230
  * 中央 DDL 里恒在,SQL 后端一开它就是 durable park/审批队列的承载面,不存在「协议不上场」的豁免形。
216
231
  */
217
- export declare function assertCheckpointBidiColumnSchema(query: (sql: string) => Promise<{
232
+ export declare function assertCheckpointColumnSchema(query: (sql: string) => Promise<{
218
233
  rows: Record<string, unknown>[];
219
234
  }>, dialect: SqlDialect): Promise<void>;
220
235
  /**
@@ -321,6 +336,17 @@ export interface DecidedApprovalRecord {
321
336
  * 铸法见 {@link approvalPayloadFingerprint}(两条车道共用同一只,免得指纹口径漂)。
322
337
  */
323
338
  payloadFp: string | null;
339
+ /**
340
+ * S-160(core 7.7.0 [ref])—— 这条已决行上**已决动作最后被怎么处置**的结算记录,`null` = **未知**。
341
+ *
342
+ * 🔴 缺席只有一个含义:未知,**绝不是**「放行过」。`decision` 说的是决定(人批了没有),这一格说的是
343
+ * 那次决定在 resume 腿上执行时的处置(部署自己的复核可能在 post-CAS 把人批过的、改过参数的调用否掉,
344
+ * 于是 `decision: "approve"` 与 denied/policy 的处置**同时**在一行上成立)。[ref] 的病正是「流断之后
345
+ * 从行重建这条 run 的消费者只读得到前半句」——decide 幂等回放正是那种消费者。
346
+ *
347
+ * 铸法 = core 的 {@link executionVerdict} **单口**(两条车道共用),不许任何读点自己判列的在场性。
348
+ */
349
+ executionOutcome: GateOutcome | null;
324
350
  }
325
351
  /**
326
352
  * [ref] 件1 —— {@link SqlCheckpointStore.findDecidedApprovalsForBinding} 的扫描上界。
@@ -417,6 +443,17 @@ export declare class SqlCheckpointStore implements CheckpointStore {
417
443
  readonly redecision: {
418
444
  readonly reopen: true;
419
445
  };
446
+ /**
447
+ * `CheckpointStore.execution` 声明(core 7.7.0 [ref];S-160)——**必填**,且必须恰是 `{ outcome: true }`:
448
+ * 店在此承诺 {@link SqlCheckpointStore.recordExecutionOutcome} 是契约描述的那条真 CAS(谓词、四词、
449
+ * 冲突抛),且 {@link SqlCheckpointStore.reopen} 认这条记录。与 `durability`/`fidelity`/`redecision`
450
+ * 三格同一条「声明而非鸭子判」姿势,区别只在这一格**不可缺席**:缺席的店会被 core 在 store 席的
451
+ * 第一次读(`resolveCheckpointStore`)以 `config.invalid_checkpoint_store` 拒启 —— 那是契约的正确行为,
452
+ * 因为一台记不下「已决动作最后被怎么处置」的部署,它手里每一条 resolved 行在这条轴上都永远读不出来。
453
+ */
454
+ readonly execution: {
455
+ readonly outcome: true;
456
+ };
420
457
  constructor(db: SqlDriver, logger?: {
421
458
  info?(msg: string, meta?: unknown): void;
422
459
  } | undefined);
@@ -485,6 +522,23 @@ export declare class SqlCheckpointStore implements CheckpointStore {
485
522
  * is validated against it — clearing it would defeat the winner-replay invariant.
486
523
  */
487
524
  reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
525
+ /**
526
+ * core 7.7.0 [ref](S-160)—— 记录**已决动作在 resume 腿上最后被怎么处置**:那一次调用的 `tool_end` 帧
527
+ * 携带的 {@link GateOutcome}。契约(答案闭集四词 / 幂等 / 冲突抛 / 一个字都不碰 status·rev·outcome·
528
+ * reopen_reason)的**判词逻辑一行都不自铸**:全部消费 core 导出的
529
+ * {@link executionRecordDisposition} —— 它是每个后端共用的那一份「CAS 谓词 + 幂等/冲突两臂」,
530
+ * 本店只留 commit 那一半(一条 `UPDATE … WHERE`)。
531
+ *
532
+ * 形 = **先读后 CAS**(与 {@link SqlCheckpointStore.setPendingSteer} 同款,理由同款):判词是**按行读**
533
+ * 出来的(要分辨 already_recorded / conflict / not_resolved / absent 四种,SQL 的 affected 只会告诉你
534
+ * 「没打中」),所以读一次交给 core 判,再把 commit 那一半压回一条带完整谓词的 UPDATE。两条语句之间那道窗
535
+ * 由 UPDATE 自己的谓词兜住:CAS 落空就重读重判(下一轮的读会分辨出到底是谁抢先了)。给足冗余仍全败 ⇒
536
+ * **抛**,绝不返回一个四词之一 —— 那等于替一次没发生的写编一个答案。
537
+ *
538
+ * 🔴 `execution_outcome` 坏 cell 在这里与 `get()` 同款 **抛**(strict parse):静默当缺席会让一份不同的
539
+ * 记录覆盖已经在案的那份,而记录物理不可变正是这条契约的全部意义。
540
+ */
541
+ recordExecutionOutcome(token: CheckpointToken, scope: string, gate: GateOutcome): Promise<ExecutionOutcomeRecordWord>;
488
542
  /**
489
543
  * [ref] D-A (durable steering) —— **core 5.14.0([ref] BREAKING)起是「追加进一条有界有序队列」**,
490
544
  * 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
@@ -560,6 +614,13 @@ export declare class SqlCheckpointStore implements CheckpointStore {
560
614
  /** Pending checkpoints for the operator queue — NON-secret fields only (never the token). `taskId` is
561
615
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
562
616
  * session claim, so the join is live for every pending row; null only in pathological windows). */
617
+ /**
618
+ * S-160 显式记账 —— **本口与 {@link SqlCheckpointStore.listByScope} 都不投执行判词,这是结构性的不是遗漏**:
619
+ * 两口的谓词都硬 `status='pending'`,而执行记录的 CAS 谓词是 `status='resolved' AND execution_outcome IS NULL`
620
+ * ⇒ 一条 pending 行**按构造**不可能带记录;反向也封死了(记过的行 `reopen` 拒,永远回不到 pending)。
621
+ * 所以这两条读面上「没有 `executionOutcome`」不是「缺席=未知」,而是「这一格在这条读面上不存在」。
622
+ * 真正需要它的读面是**已决行**的那一条:`findDecidedApprovalsForBinding`(decide 幂等回放),见那里。
623
+ */
563
624
  listPending(scope?: string): Promise<PendingCheckpoint[]>;
564
625
  /**
565
626
  * [ref] #2 (scheduler seam, core 1.105): a scope's PENDING checkpoints as lightweight
@@ -1,7 +1,7 @@
1
1
  import { contentKindOf } from "../approval-content-kind.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
- import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
4
+ import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
6
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
7
7
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
@@ -32,23 +32,41 @@ export function pendingRowPrecedes(a, b, pick) {
32
32
  return a.createdAt < b.createdAt || (a.createdAt === b.createdAt && a.token < b.token);
33
33
  return a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.token > b.token);
34
34
  }
35
- export async function assertCheckpointBidiColumnSchema(query, dialect) {
36
- try {
37
- await query("SELECT has_bidi_controls FROM checkpoint WHERE 1=0");
38
- }
39
- catch (err) {
40
- if (!isMissingColumnError(err, dialect))
41
- throw err;
42
- throw new Error("checkpoint is missing the S-30 bidi-presence column (has_bidi_controls) — refusing to start. " +
43
- "The park-mint write (put) and the operator-queue reads (GET /v1/approvals, /v1/approvals/stream) name the column " +
44
- "unconditionally, so on this table every durable suspend fails at first park and every queue poll 500s — at runtime, " +
45
- "after a healthy-looking boot. This repository ships no ALTER TABLE migrations, so the table must be recreated: run " +
46
- "`DROP TABLE checkpoint;` on the " +
47
- (dialect === "pg" ? "PostgreSQL" : "MySQL-protocol") +
48
- " backend and restart — the schema is recreated at boot. The cost is bounded but real: the table holds IN-FLIGHT " +
49
- "durable parks (suspended approvals/reviews awaiting a decision) — those cards are lost and their runs must be " +
50
- "re-triggered; `checkpoint_ctx`, run rows and every other persistent face are untouched. " +
51
- `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
35
+ export const CHECKPOINT_ADDED_COLUMNS = [
36
+ {
37
+ column: "has_bidi_controls",
38
+ breaksAt: "the park-mint write (put) and the operator-queue reads (GET /v1/approvals, /v1/approvals/stream) name it " +
39
+ "unconditionally, so every durable suspend fails at first park and every queue poll 500s",
40
+ },
41
+ {
42
+ column: "execution_outcome",
43
+ breaksAt: "the execution-outcome record (CheckpointStore.recordExecutionOutcome, core 7.7.0 #602) writes it under a CAS and " +
44
+ "`reopen` reads it as a predicate, so every resumed approval fails to file how the decided action was disposed and " +
45
+ "every reopen statement errors",
46
+ },
47
+ {
48
+ column: "execution_at_ms",
49
+ breaksAt: "it is written in the same CAS as `execution_outcome` (the record and its stamp are one write group)",
50
+ },
51
+ ];
52
+ export async function assertCheckpointColumnSchema(query, dialect) {
53
+ for (const { column, breaksAt } of CHECKPOINT_ADDED_COLUMNS) {
54
+ try {
55
+ await query(`SELECT ${column} FROM checkpoint WHERE 1=0`);
56
+ }
57
+ catch (err) {
58
+ if (!isMissingColumnError(err, dialect))
59
+ throw err;
60
+ throw new Error(`checkpoint is missing the column ${column} — refusing to start. ` +
61
+ `On this table ${breaksAt} — at runtime, after a healthy-looking boot. ` +
62
+ "This repository ships no ALTER TABLE migrations, so the table must be recreated: run " +
63
+ "`DROP TABLE checkpoint;` on the " +
64
+ (dialect === "pg" ? "PostgreSQL" : "MySQL-protocol") +
65
+ " backend and restart — the schema is recreated at boot. The cost is bounded but real: the table holds IN-FLIGHT " +
66
+ "durable parks (suspended approvals/reviews awaiting a decision) — those cards are lost and their runs must be " +
67
+ "re-triggered; `checkpoint_ctx`, run rows and every other persistent face are untouched. " +
68
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
52
70
  }
53
71
  }
54
72
  const MAX_RULE_OFFER_SCAN = 32;
@@ -266,6 +284,7 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
266
284
  };
267
285
  }
268
286
  const STEER_CAS_ATTEMPTS = 8;
287
+ const EXECUTION_RECORD_CAS_ATTEMPTS = 8;
269
288
  export class SqlCheckpointStore {
270
289
  db;
271
290
  logger;
@@ -273,6 +292,7 @@ export class SqlCheckpointStore {
273
292
  durability = "durable";
274
293
  fidelity = "json";
275
294
  redecision = { reopen: true };
295
+ execution = { outcome: true };
276
296
  constructor(db, logger) {
277
297
  this.db = db;
278
298
  this.logger = logger;
@@ -318,14 +338,17 @@ export class SqlCheckpointStore {
318
338
  const toolInput = boundedToolInput(pa?.args);
319
339
  const ruleOffers = boundedRuleOffers(pa?.ruleOffers);
320
340
  const hasBidiControls = pa?.hasBidiControls === true ? 1 : null;
341
+ const executionOutcome = cp.executionOutcome === undefined ? null : this.json(cp.executionOutcome, "execution outcome");
342
+ const executionAtMs = cp.executionOutcome === undefined ? null : (cp.executionAtMs ?? null);
321
343
  try {
322
- await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls) " +
323
- "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls) " +
324
- "VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16)"), [
344
+ await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls, rev, reopen_reason, execution_outcome, execution_at_ms) " +
345
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls, rev, reopen_reason, execution_outcome, execution_at_ms) " +
346
+ "VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb,$21)"), [
325
347
  token,
326
348
  cp.scope,
327
349
  cp.sessionId,
328
350
  version,
351
+ cp.status,
329
352
  pa?.toolName ?? null,
330
353
  pa?.toolCallId ?? null,
331
354
  toolInput === null ? null : this.json(toolInput, "approval tool_input"),
@@ -338,6 +361,10 @@ export class SqlCheckpointStore {
338
361
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
339
362
  ruleOffers === undefined ? null : this.json(ruleOffers, "rule offers"),
340
363
  hasBidiControls,
364
+ cp.rev ?? 0,
365
+ cp.reopenReason ?? null,
366
+ executionOutcome,
367
+ executionAtMs,
341
368
  ]);
342
369
  }
343
370
  catch (e) {
@@ -348,7 +375,7 @@ export class SqlCheckpointStore {
348
375
  }
349
376
  }
350
377
  async get(token) {
351
- const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = $1"), [token]);
378
+ const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
352
379
  const r = rows[0];
353
380
  if (!r)
354
381
  return null;
@@ -363,6 +390,12 @@ export class SqlCheckpointStore {
363
390
  cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
364
391
  cp.reopenReason = r.reopen_reason ?? undefined;
365
392
  cp.rev = r.rev == null ? 0 : Number(r.rev);
393
+ const executionOutcome = parseJson(r.execution_outcome);
394
+ if (executionOutcome !== null) {
395
+ cp.executionOutcome = executionOutcome;
396
+ if (r.execution_at_ms != null)
397
+ cp.executionAtMs = Number(r.execution_at_ms);
398
+ }
366
399
  const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
367
400
  if (steer.lossy) {
368
401
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
@@ -392,9 +425,32 @@ export class SqlCheckpointStore {
392
425
  return res.affected === 1;
393
426
  }
394
427
  async reopen(token, scope, reason) {
395
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved'", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved'"), [reason, token, scope]);
428
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved' AND execution_outcome IS NULL", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved' AND execution_outcome IS NULL"), [reason, token, scope]);
396
429
  return res.affected === 1;
397
430
  }
431
+ async recordExecutionOutcome(token, scope, gate) {
432
+ for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
433
+ const { rows } = await this.db.query(this.q("SELECT scope, status, execution_outcome FROM checkpoint WHERE token = ?", "SELECT scope, status, execution_outcome FROM checkpoint WHERE token = $1"), [token]);
434
+ const row = rows[0];
435
+ const facts = row === undefined
436
+ ? undefined
437
+ : {
438
+ scope: String(row.scope),
439
+ status: row.status,
440
+ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(row.execution_outcome)),
441
+ };
442
+ const disposition = executionRecordDisposition(facts, scope, gate);
443
+ if (disposition === "conflict")
444
+ throw new ExecutionOutcomeConflictError(facts.executionOutcome, gate);
445
+ if (disposition !== "commit")
446
+ return disposition;
447
+ const res = await this.db.query(this.q("UPDATE checkpoint SET execution_outcome = ?, execution_at_ms = ? WHERE token = ? AND scope = ? AND status = 'resolved' AND execution_outcome IS NULL", "UPDATE checkpoint SET execution_outcome = $1::jsonb, execution_at_ms = $2 WHERE token = $3 AND scope = $4 AND status = 'resolved' AND execution_outcome IS NULL"), [this.json(gate, "execution outcome"), Date.now(), token, scope]);
448
+ if (res.affected === 1)
449
+ return "recorded";
450
+ }
451
+ throw new Error(`recordExecutionOutcome lost the execution-record CAS ${EXECUTION_RECORD_CAS_ATTEMPTS} times on checkpoint ${tokenFingerprint(token)} — ` +
452
+ "refusing to answer with one of the four contract words (that would invent an account of a write that did not happen)");
453
+ }
398
454
  async setPendingSteer(token, scope, steer) {
399
455
  const clean = validatePendingSteer(steer);
400
456
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
@@ -529,8 +585,8 @@ export class SqlCheckpointStore {
529
585
  return out;
530
586
  }
531
587
  async findDecidedApprovalsForBinding(sessionId, boundCallId) {
532
- const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome FROM checkpoint " +
533
- `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome FROM checkpoint " +
588
+ const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
589
+ `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
534
590
  `WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
535
591
  return rows.map((r) => {
536
592
  let winner;
@@ -541,11 +597,20 @@ export class SqlCheckpointStore {
541
597
  catch (e) {
542
598
  this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
543
599
  }
600
+ let executed = null;
601
+ try {
602
+ const verdict = executionVerdict({ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(r.execution_outcome)) });
603
+ executed = verdict.kind === "executed" ? verdict.gate : null;
604
+ }
605
+ catch (e) {
606
+ this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
607
+ }
544
608
  return {
545
609
  scope: String(r.scope),
546
610
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
547
611
  decision: approvalDecisionOfWinner(winner, boundCallId),
548
612
  payloadFp: winner === undefined ? null : approvalPayloadFingerprint(winner),
613
+ executionOutcome: executed,
549
614
  };
550
615
  });
551
616
  }
@@ -1,4 +1,4 @@
1
- import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ReopenReason, type ResolveExpectation, type ResumeOutcome, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
1
+ import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ReopenReason, type ResolveExpectation, type ExecutionOutcomeRecordWord, type GateOutcome, type ResumeOutcome, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
2
2
  import { type DecidedApprovalRecord, type PendingCheckpoint, type PendingRowBinding } from "./checkpoint-store-sql.js";
3
3
  export interface LocalCheckpointStoreOptions {
4
4
  /** taskId join for the pending card (the local FileRunStore's `getActiveTaskId`). Absent ⇒ `taskId: null`. */
@@ -40,6 +40,20 @@ export declare class LocalCheckpointStore {
40
40
  readonly redecision: {
41
41
  readonly reopen: true;
42
42
  };
43
+ /**
44
+ * `CheckpointStore.execution` 声明(core 7.7.0 [ref];S-160)——**必填**,与上面三格同一条判据:**如实
45
+ * 按内核判**。内核 core `FileCheckpointStore` 自己声明 `{ outcome: true }`,并把执行记录落成 ledger 的
46
+ * 一个新事件种类(`execution`,与 resolve 同款「先 append+fsync 再翻内存」的崩溃序),
47
+ * {@link LocalCheckpointStore.recordExecutionOutcome} 与 {@link LocalCheckpointStore.reopen} 都是纯委派
48
+ * ⇒ 抄内核的读数是诚实的。包装类**不继承**被包装者的声明(委派不是子类),所以这一格必须自己写。
49
+ *
50
+ * ⚠️ 升级序的诚实披露(DEPLOY-PREREQS 同句):ledger 里一旦出现 `execution` 事件,**7.7.0 之前的
51
+ * binary 会拒绝重放这个目录**(响亮,不是静默跳过);而压缩把事件折进快照行之后,那个旧 binary 读到
52
+ * 一个它不认识的字段、且它的 `reopen` 不认这条记录 —— 跨记录行回滚不受支持。
53
+ */
54
+ readonly execution: {
55
+ readonly outcome: true;
56
+ };
43
57
  private readonly inner;
44
58
  private readonly scopesPath;
45
59
  private readonly sessionTokensPath;
@@ -63,6 +77,10 @@ export declare class LocalCheckpointStore {
63
77
  get(token: CheckpointToken): Promise<Checkpoint | null>;
64
78
  resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise<boolean>;
65
79
  reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
80
+ /** core 7.7.0 [ref](S-160):执行判词的 CAS —— 语义、四词、幂等的结构相等与冲突抛全在被包的内核里
81
+ * (它自己走 core 的共享判词 `executionRecordDisposition`,ledger 事件 `execution` 是它的 commit 半场),
82
+ * 本 wrapper 只做转发。服务扩展面(运维队列/会话索引)与这条记录无关:它不改 status,也不新增可枚举行。 */
83
+ recordExecutionOutcome(token: CheckpointToken, scope: string, gate: GateOutcome): Promise<ExecutionOutcomeRecordWord>;
66
84
  /** core 5.14.0([ref]):入参是 `PendingSteerInput`(text/trusted + 可选 actor/inputId/priority),不是
67
85
  * 两键单座 —— 形参窄成 `{text,trusted}` 会**静默丢掉**署名与幂等键(TS 的方法参数是双变的,窄形不
68
86
  * 报错,只是把三个可选键从类型上抹掉,调用点再也传不进来)。队列语义与边界全在被包的 core
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { isApprovalGateKind } from "../tool-approval.js";
6
- import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
6
+ import { FileCheckpointStore, atomicWriteFile, executionVerdict, sanitizePathComponent, } from "@sema-agent/core";
7
7
  import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, pendingRowPrecedes, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
8
8
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
9
9
  function terminalAtOf(cp) {
@@ -17,6 +17,7 @@ export class LocalCheckpointStore {
17
17
  durability = "durable";
18
18
  fidelity = "json";
19
19
  redecision = { reopen: true };
20
+ execution = { outcome: true };
20
21
  inner;
21
22
  scopesPath;
22
23
  sessionTokensPath;
@@ -95,6 +96,9 @@ export class LocalCheckpointStore {
95
96
  reopen(token, scope, reason) {
96
97
  return this.inner.reopen(token, scope, reason);
97
98
  }
99
+ recordExecutionOutcome(token, scope, gate) {
100
+ return this.inner.recordExecutionOutcome(token, scope, gate);
101
+ }
98
102
  setPendingSteer(token, scope, steer) {
99
103
  return this.inner.setPendingSteer(token, scope, steer);
100
104
  }
@@ -227,6 +231,7 @@ export class LocalCheckpointStore {
227
231
  boundInputHash: pa?.boundInputHash ?? null,
228
232
  decision: approvalDecisionOfWinner(cp.resolvedOutcome, boundCallId),
229
233
  payloadFp: cp.resolvedOutcome === undefined ? null : approvalPayloadFingerprint(cp.resolvedOutcome),
234
+ ...((v) => ({ executionOutcome: v.kind === "executed" ? v.gate : null }))(executionVerdict(cp)),
230
235
  });
231
236
  }
232
237
  return out;
@@ -95,6 +95,11 @@ export const PG_SCHEMA_STATEMENTS = [
95
95
  -- has_bidi_controls(S-30① / #356②):bidi 在场位,语义与「1 或 NULL、恒不写 0」见 MySQL 孪生同名列的
96
96
  -- 行内注。clay 批 TINYINT(1) 归一先例:PG 侧统一 SMALLINT。
97
97
  has_bidi_controls SMALLINT,
98
+ -- execution_outcome / execution_at_ms(S-160;core 7.7.0 #602):语义、两轴的分工、CAS 与冲突的
99
+ -- 「记录物理不可变」由来见 MySQL 孪生(tidb-pool.ts 同两列的行内注)。载荷是 core 的 GateOutcome
100
+ -- JSON ⇒ 与同表 outcome 列同取 JSONB(协议信封无损,IS NULL 谓词两方言同义)。
101
+ execution_outcome JSONB,
102
+ execution_at_ms BIGINT,
98
103
  PRIMARY KEY (token)
99
104
  )`,
100
105
  `CREATE INDEX IF NOT EXISTS idx_checkpoint_status_deadline ON checkpoint (status, deadline)`,
@@ -266,6 +266,17 @@ export const SCHEMA_STATEMENTS = [
266
266
  -- (缺席 = 没检出 ≠ 干净),一个 0 会把「没检出」谎成「已确认干净」。老行(本列诞生前 park 的)
267
267
  -- NULL ⇒ 读口省键。clay 批 2026-07-26 TINYINT(1) 归一先例:布尔列 TiDB 侧 TINYINT(1)。
268
268
  has_bidi_controls TINYINT(1) NULL,
269
+ -- execution_outcome / execution_at_ms(S-160;core 7.7.0 #602 的耐久半场):**决定被怎么处置了**的
270
+ -- 结算记录 —— resume 腿上那次调用的 tool_end 帧携带的 GateOutcome,与 outcome 列(决定本身,不可变)
271
+ -- 是两个轴:一个人批了(outcome 里 disposition=allowed),部署自己的复核在 post-CAS 否了他改过的参数
272
+ -- (execution_outcome 里 denied/policy)—— 流断了之后从行重建这条 run 的消费者,此前只读得到前半句。
273
+ -- 写口是 recordExecutionOutcome 的 CAS(status='resolved' AND execution_outcome IS NULL),**恰好一次**;
274
+ -- 第二次写同一份记录是幂等,写不同的一份是 checkpoint.execution_outcome_conflict(记录物理不可变 ——
275
+ -- 两份不同的执行记录意味着同一个已决动作被结算了两次,那是引擎缺陷,不是店可以吸收的条件)。
276
+ -- 缺席只有一个含义:**未知**,绝不是「放行过」(读面单口 core executionVerdict(cp))。
277
+ -- 两列同生同灭(stamp 在场 ⇔ 记录在场);reopen 见到记录即拒(已结算的动作不可重开)。
278
+ execution_outcome JSON NULL,
279
+ execution_at_ms BIGINT NULL,
269
280
  PRIMARY KEY (token),
270
281
  KEY idx_checkpoint_status_deadline (status, deadline),
271
282
  KEY idx_checkpoint_session_status (session_id, status)
@@ -110,11 +110,21 @@ export declare function assertRequestMcpUnlocked(bodyMcp: unknown, lockedKeys: R
110
110
  */
111
111
  export declare function assertRequestMcpContentOrigin(bodyMcp: unknown): void;
112
112
  /**
113
- * Merge GATED per-request MCP servers OVER the deployment baseline (center/config). The baseline WINS on a name
114
- * clash — a caller can ADD servers but can never SHADOW a configured one (the security baseline is authoritative,
115
- * same posture as `mergeUserSkills`). Returns the baseline unchanged when there is nothing to add.
113
+ * The ONE predicate for "this MCP server name belongs to the deployment": the scenario baseline (what mounted for
114
+ * this scenario) ∪ every name the deployment DECLARED (`CenterMcp.reservedNames` — mounted or not, any scenario).
115
+ * A caller can ADD servers but can never SHADOW or REFILL a deployment-declared one: a center entry that was skipped
116
+ * (disabled / missing env ref / malformed `toolFaces`) still occupies its name, otherwise "skipped" would silently
117
+ * mean "free for the caller to remount without the operator's `allowTools` / `toolFaces`" (车CF codex r2 [high]).
118
+ * Same posture as `mergeUserSkills`.
116
119
  */
117
- export declare function mergeRequestMcp(baseline: McpServerSpec[] | undefined, gated: McpServerSpec[]): McpServerSpec[] | undefined;
120
+ export declare function deploymentClosedMcpNames(baseline: McpServerSpec[] | undefined, reservedNames: readonly string[]): ReadonlySet<string>;
121
+ /**
122
+ * Merge GATED per-request MCP servers OVER the deployment baseline (center/config). Names closed by the deployment
123
+ * ({@link deploymentClosedMcpNames}) are dropped from `gated`. `reservedNames` has NO default: a security predicate's
124
+ * input is never implied — a caller with no deployment declaration passes `[]` and says so. Returns the baseline unchanged
125
+ * when nothing is added.
126
+ */
127
+ export declare function mergeRequestMcp(baseline: McpServerSpec[] | undefined, gated: McpServerSpec[], reservedNames: readonly string[]): McpServerSpec[] | undefined;
118
128
  /**
119
129
  * The single entry point the spec-builder calls: compute the effective `TaskSpec.mcp` = deployment baseline + the
120
130
  * caller's gated per-request servers. Off the honored lane (or with no caller servers) returns the baseline
@@ -127,6 +137,10 @@ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined,
127
137
  };
128
138
  requirePrincipal?: boolean;
129
139
  lockedConfigKeys?: readonly LockedKey[];
140
+ /** The deployment's declared-name set rides on the same config object the gate reads (one owner, no second arg). */
141
+ mcpServers?: {
142
+ reservedNames: readonly string[];
143
+ };
130
144
  }, logger?: {
131
145
  info?(m: string, meta?: unknown): void;
132
146
  warn(m: string, meta?: unknown): void;
package/dist/task-mcp.js CHANGED
@@ -2,6 +2,7 @@ import { resolveComplianceDenies } from "@sema-agent/core";
2
2
  import { HttpError } from "./security.js";
3
3
  import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "./mcp-content-origin.js";
4
4
  import { readMcpSourceLabel } from "./mcp-source-label.js";
5
+ import { readMcpToolFaces } from "./mcp-tool-faces.js";
5
6
  export const MAX_REQUEST_MCP_SERVERS = 32;
6
7
  export function mcpInjectionHonored(config) {
7
8
  if (config.lockedConfigKeys?.includes("mcp") === true)
@@ -79,6 +80,11 @@ function normalizeServer(e, noteBadOrigin) {
79
80
  noteBadOrigin(spec.name, o.contentOrigin);
80
81
  }
81
82
  Object.assign(spec, readMcpSourceLabel(spec.name, o.source));
83
+ const faces = readMcpToolFaces(o.toolFaces);
84
+ if (!faces.ok)
85
+ return null;
86
+ if (faces.faces !== undefined)
87
+ spec.toolFaces = faces.faces;
82
88
  return spec;
83
89
  }
84
90
  export function validateRequestMcp(raw) {
@@ -124,11 +130,14 @@ export function assertRequestMcpContentOrigin(bodyMcp) {
124
130
  ` (omit the key entirely for the default: this server's tools classify as external content)`, { code: "request.field_invalid" });
125
131
  }
126
132
  }
127
- export function mergeRequestMcp(baseline, gated) {
133
+ export function deploymentClosedMcpNames(baseline, reservedNames) {
134
+ return new Set([...(baseline ?? []).map((s) => s.name), ...reservedNames]);
135
+ }
136
+ export function mergeRequestMcp(baseline, gated, reservedNames) {
128
137
  if (gated.length === 0)
129
138
  return baseline;
130
- const taken = new Set((baseline ?? []).map((s) => s.name));
131
- const add = gated.filter((s) => !taken.has(s.name));
139
+ const closed = deploymentClosedMcpNames(baseline, reservedNames);
140
+ const add = gated.filter((s) => !closed.has(s.name));
132
141
  return add.length === 0 ? baseline : [...(baseline ?? []), ...add];
133
142
  }
134
143
  export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
@@ -153,6 +162,16 @@ export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
153
162
  note: "declaration DROPPED — these servers keep the pre-378 default (external content, session gets marked); the servers themselves still mount",
154
163
  });
155
164
  }
156
- return mergeRequestMcp(baseline, v.ok);
165
+ const reservedNames = config.mcpServers?.reservedNames ?? [];
166
+ const closed = deploymentClosedMcpNames(baseline, reservedNames);
167
+ const blocked = v.ok.filter((s) => closed.has(s.name)).map((s) => s.name);
168
+ if (blocked.length > 0) {
169
+ logger?.warn("mcp_injection_dropped", {
170
+ reason: "name_reserved_by_deployment",
171
+ dropped: blocked,
172
+ note: "these names are declared by the deployment (center/config) — mounted or skipped, a caller can neither shadow nor refill them; rename the request-side server",
173
+ });
174
+ }
175
+ return mergeRequestMcp(baseline, v.ok, reservedNames);
157
176
  }
158
177
  //# sourceMappingURL=task-mcp.js.map
@@ -1,6 +1,6 @@
1
1
  import { contentKindOf } from "./approval-content-kind.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { uuidv7, MAX_RULE_TEXT_CHARS, parseAllowRuleText } from "@sema-agent/core";
3
+ import { uuidv7, MAX_RULE_TEXT_CHARS, parseAllowRuleText, pathConfinableWriteToolNames } from "@sema-agent/core";
4
4
  import { precheckCardRuleText } from "./rules-consent.js";
5
5
  import { redactDeep, redactSecrets } from "./trace/redact.js";
6
6
  import { hasBidiControls } from "./text-bidi.js";
@@ -69,7 +69,7 @@ class ApprovalStoreDeadlineError extends Error {
69
69
  this.name = "ApprovalStoreDeadlineError";
70
70
  }
71
71
  }
72
- const FS_WRITE_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]);
72
+ const FS_WRITE_TOOLS = pathConfinableWriteToolNames();
73
73
  function sessionAllowCategory(toolName) {
74
74
  return FS_WRITE_TOOLS.has(toolName) ? "fs-write" : `tool:${toolName}`;
75
75
  }
@@ -31,7 +31,7 @@ type _GuardRoster = AssertAllKeysHandled<Exclude<keyof RosterEntry, RosterProjec
31
31
  type AskProjected = "toolName" | "toolCallId" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation" | "ruleOffers" | "ruleOffersAbsence" | "persistedRuleShadowed" | "probeCause" | "ruleEvidence" | "requiresRealApproval" | "denialLimitFallback" | "origin";
32
32
  type AskExcluded = "preview" | "principal" | "riskAxes" | "boundInputHash" | "isDelegatedChild" | "probeReason" | "hasBidiControls" | "previewWithheld" | "execCwd";
33
33
  type _GuardAsk = AssertAllKeysHandled<Exclude<keyof AskRequest, AskProjected | AskExcluded>>;
34
- type TaskEventHandled = "text_delta" | "reasoning_delta" | "tool_start" | "tool_end" | "turn_end" | "compacted" | "diagnostics" | "message_committed" | "status" | "task_notification" | "task_progress" | "steering_injected" | "workspace_changed" | "done" | "context_usage" | "compaction_outcome" | "human_input" | "wiring_manifest" | "text_end";
34
+ type TaskEventHandled = "text_delta" | "reasoning_delta" | "tool_start" | "tool_end" | "turn_end" | "compacted" | "diagnostics" | "message_committed" | "status" | "task_notification" | "task_progress" | "steering_injected" | "workspace_changed" | "done" | "context_usage" | "compaction_outcome" | "human_input" | "wiring_manifest" | "text_end" | "tool_roster_delta";
35
35
  type _GuardTaskEvent = AssertAllKeysHandled<Exclude<TaskEvent["type"], TaskEventHandled>>;
36
36
  type MailboxProjected = "seq" | "from" | "content" | "sentAt" | "hopChain" | "peerMeta";
37
37
  type _GuardMailbox = AssertAllKeysHandled<Exclude<keyof MailboxMessage, MailboxProjected>>;
@@ -49,7 +49,7 @@ type SummaryProjected = "sessionId" | "scope" | "createdAt" | "gateKind" | "seve
49
49
  type SummaryExcluded = "token" | "toolInput" | "toolName" | "toolCallId" | "preview" | "principal" | "sourceTaskId" | "contentKind" | "restoreMode" | "checkpointId" | "previewWithheld";
50
50
  type _GuardInboxSummary = AssertAllKeysHandled<Exclude<keyof CheckpointSummary, SummaryProjected | SummaryExcluded>>;
51
51
  type TaskSpecWired = "objective" | "principal" | "images" | "sessionId" | "requireExistingSession" | "oneShot" | "clientContext" | "resumeAt" | "resumeAtMode" | "model" | "compactionModel" | "thinking" | "systemPrompt" | "appendSystemPrompt" | "promptProvider" | "memory" | "suggestNextPrompts" | "restoreFiles" | "acceptPartialRestore" | "rewindFilesTo" | "tools" | "excludeTools" | "deferTools" | "toolMaterializeStrategy" | "promptProfile" | "toolPolicy" | "onAsk" | "onQuestion" | "hooks" | "durableApproval" | "resourceSuspend" | "resilience" | "finalVerification" | "checkpointStore" | "handsReadOnly" | "memoryPersistenceCapable" | "additionalDirectories" | "additionalReadDirectories" | "enablePlanMode" | "interactiveTools" | "enableFork" | "shellGate" | "mcp" | "a2a" | "skills" | "backgroundScope" | "envFacts" | "limits" | "configOverrides" | "forwardSubagentEvents" | "retainSubagentSessions" | "retainBackgroundProcesses" | "outputSchema" | "outputRetries" | "compaction" | "attachments" | "selfOrchestration" | "getApiKeyAndHeaders" | "interactionPosture" | "agents" | "taskId" | "signal" | "preemptSignal" | "modelRole" | "enableBlockedReport" | "maxSuspends" | "autoModeRequested";
52
- type TaskSpecUnwired = "actor" | "roles" | "lspManager" | "tracer" | "readFace" | "readDenyPatterns" | "interactiveQuestionFallback" | "alwaysLoadTools" | "restoreGatedTools" | "deferSelfResolve" | "basePolicyForResumeEdit" | "lspDiagnostics" | "streamingToolExecution" | "toolResultThresholdChars";
52
+ type TaskSpecUnwired = "excludeAllTools" | "actor" | "roles" | "lspManager" | "tracer" | "readFace" | "readDenyPatterns" | "interactiveQuestionFallback" | "alwaysLoadTools" | "restoreGatedTools" | "deferSelfResolve" | "basePolicyForResumeEdit" | "lspDiagnostics" | "streamingToolExecution" | "toolResultThresholdChars";
53
53
  type _GuardTaskSpec = AssertAllKeysHandled<Exclude<keyof TaskSpec, TaskSpecWired | TaskSpecUnwired>>;
54
54
  type _GuardTaskSpecReverse = AssertAllKeysHandled<Exclude<TaskSpecWired | TaskSpecUnwired, keyof TaskSpec>>;
55
55
  type TaskResultForwarded = "taskId" | "runId" | "sessionId" | "terminal" | "model" | "effectiveReasoning" | "result" | "salvagedOutput" | "remoteEnvFailures" | "apiFailure" | "retryAfterMs" | "degraded" | "structuredOutput" | "rewindNotes" | "haltedOnUserRejection" | "haltedByUser" | "strandedHumanAnswers" | "pendingApproval" | "approvalWaitedMs" | "effectiveReadFace" | "effectiveReadDenyPatterns" | "effectiveMemoryScopes" | "stats" | "editedFiles";
@@ -407,6 +407,27 @@ export declare function workspaceChangedEventData(ev: {
407
407
  * 都红不了(2026-09-04 实测:该钉 26/26 全绿,红出 `autoMode` 的是编译期门)。段级 + 成员级的**编译期**门在
408
408
  * `core-keyset-guard.ts` ⑥b —— 那才是「core 加了段/加了成员」的可靠红点,两道并用缺一不可。
409
409
  */
410
+ /**
411
+ * `tool_roster_delta`(core 7.8.0 [ref] ⑪ 新 `TaskEvent` 臂)—— **运行期**名册变更:整只变更后名册
412
+ * (`delta.roster`)+ 一段只有持有 `fromDigest` 的消费者才用得上的摘要。
413
+ *
414
+ * 契约里唯一的硬话是失败方向:`fromDigest` 对不上**不是拒绝** —— 携带的整只名册无论如何都是新状态,
415
+ * 消费端记一次 skew 后按快照重同步。所以本构造器**不**替消费端判 digest,只判「这一帧的载荷是不是
416
+ * core 说的那个形」。
417
+ *
418
+ * 判形与 {@link wiringManifestEventData} 的 `tools` 段同一条姿势(理由逐字见那里):属主是 core 的
419
+ * typebox schema `ToolRosterDelta`,过了整只透传,没过 ⇒ **整帧不发** + F 类留痕。半张名册变更帧比
420
+ * 不发更坏:消费端会据它把没读到的行当成「这只工具被卸了」。
421
+ *
422
+ * ⚠️ **如实披露(core 7.8.0 自述)**:7.8.0 的 runtask **还没有**推这条帧([ref] S3 才接线),座位与
423
+ * 类型先到货。本臂因此是**前瞻接线**:core 开始推的那一版,server 这一侧零改动即到达消费端;在那之前
424
+ * 它永不触发。消费端在那一版之前**不得**依赖本帧(core 契约原文)。
425
+ */
426
+ export declare function toolRosterDeltaEventData(ev: {
427
+ delta?: unknown;
428
+ eventId?: unknown;
429
+ parentToolCallId?: unknown;
430
+ }): Record<string, unknown> | undefined;
410
431
  export declare function wiringManifestEventData(ev: {
411
432
  manifest?: unknown;
412
433
  eventId?: unknown;
@@ -1,5 +1,6 @@
1
1
  import { redactErrorMessage } from "../observability/run-terminal-log.js";
2
- import { MCP_DELIVERY_VERDICTS, screenGateOutcome } from "@sema-agent/core";
2
+ import { MCP_DELIVERY_VERDICTS, screenGateOutcome, ToolRoster, ToolRosterDelta } from "@sema-agent/core";
3
+ import { Value } from "typebox/value";
3
4
  import { recordFailOpen } from "../observability/fail-open.js";
4
5
  import { redactSecrets, redactDeep } from "./redact.js";
5
6
  import { injectionTierOrAbsent } from "./injection-tier.js";
@@ -94,6 +95,15 @@ export function screenedGate(v) {
94
95
  recordFailOpen("server.trace.gate-outcome-defective", "disposition is not one of the two arms");
95
96
  return undefined;
96
97
  }
98
+ const shapeDefect = (d.kind === "denied" && typeof d.deniedBy !== "string" && "disposition.deniedBy is not a string") ||
99
+ (g.settlement !== undefined && (g.settlement === null || typeof g.settlement !== "object") && "settlement is not an object") ||
100
+ (g.settlement !== undefined && g.settlement !== null && typeof g.settlement.kind !== "string" && "settlement.kind is not a string") ||
101
+ (g.origin !== undefined && typeof g.origin !== "string" && "origin is not a string") ||
102
+ undefined;
103
+ if (shapeDefect !== undefined) {
104
+ recordFailOpen("server.trace.gate-outcome-defective", shapeDefect);
105
+ return undefined;
106
+ }
97
107
  const defects = screenGateOutcome(g);
98
108
  if (defects.length > 0) {
99
109
  recordFailOpen("server.trace.gate-outcome-defective", defects.join("; "));
@@ -250,6 +260,13 @@ export function workspaceChangedEventData(ev) {
250
260
  ...identityFields(ev),
251
261
  };
252
262
  }
263
+ export function toolRosterDeltaEventData(ev) {
264
+ if (!Value.Check(ToolRosterDelta, ev.delta)) {
265
+ recordFailOpen("server.trace.tool-roster-malformed", "tool_roster_delta.delta did not fit core ToolRosterDelta");
266
+ return undefined;
267
+ }
268
+ return { delta: ev.delta, ...identityFields(ev) };
269
+ }
253
270
  export function wiringManifestEventData(ev) {
254
271
  const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
255
272
  const bool = (v) => (typeof v === "boolean" ? v : undefined);
@@ -314,6 +331,8 @@ export function wiringManifestEventData(ev) {
314
331
  return undefined;
315
332
  return armed === (reason === "armed") ? { armed, reason } : undefined;
316
333
  })();
334
+ const toolsIn = m.tools;
335
+ const tools = toolsIn === undefined ? undefined : Value.Check(ToolRoster, toolsIn) ? toolsIn : (recordFailOpen("server.trace.tool-roster-malformed", "wiring_manifest.tools did not fit core ToolRoster"), undefined);
317
336
  const mcpIn = m.mcp;
318
337
  const mcp = (() => {
319
338
  if (!Array.isArray(mcpIn))
@@ -352,6 +371,7 @@ export function wiringManifestEventData(ev) {
352
371
  ...(permissionRules !== undefined ? { permissionRules } : {}),
353
372
  ...(modelGate !== undefined ? { modelGate } : {}),
354
373
  ...(autoMode !== undefined ? { autoMode } : {}),
374
+ ...(tools !== undefined ? { tools } : {}),
355
375
  ...(mcp !== undefined ? { mcp } : {}),
356
376
  ...identityFields(ev),
357
377
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.65.0",
3
+ "version": "7.66.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",
@@ -54,8 +54,8 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "7.6.2",
58
- "@sema-agent/settings-schema": "1.8.0",
57
+ "@sema-agent/core": "7.8.0",
58
+ "@sema-agent/settings-schema": "1.9.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
61
61
  "mysql2": "^3.22.4",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "8.4.0",
72
+ "@sema-agent/sdk": "8.5.0",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",