@sema-agent/server 7.129.1 → 7.130.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.
@@ -241,6 +241,23 @@ export const ONE_TIME_MIGRATIONS = [
241
241
  "引擎续跑不受影响:续跑入口读行摘要、不按今日闭集重判停驻行上的这两座,行字节引擎怎么写的还是怎么在",
242
242
  reportKeys: ["retiredCardWordAskRowsRewritten", "retiredCardWordRowsUnreadable"],
243
243
  },
244
+ {
245
+ id: "M19",
246
+ introducedIn: "7.130.0",
247
+ releasedOn: "2026-10-16",
248
+ removableAfter: "2026-11-15",
249
+ removal: "删 `src/stored-request-tool-axes-m19.ts` 整只文件(`storedToolAxesRereadM19` / `readToolAxesAsServer7129M19` / `StoredToolAxesRereadM19`);" +
250
+ "`boot/resolve-spec.ts` 里递给 `resolveRequestMcp` 的那一实参(`opts?.leg === \"fresh\" ? undefined : storedToolAxesRereadM19(…)`)与它的 import;" +
251
+ "`task-mcp.ts` 里 `resolveRequestMcp` / `classifyRequestMcp` / `classifyHonored` / `validateRequestMcpEntries` / `normalizeServer` 的 `storedToolAxes` 形参、" +
252
+ "`toolAxesReread` 事实与运维行 `mcp_tool_axes_read_as_before_upgrade`,以及对本模块的型 import;`tool-axis-words.ts` 头注与两只谓词 JSDoc 里点名 M19 的那几处;" +
253
+ "`test/mcp-tool-axes-m19.test.ts`(窗关那一格 —— 存量坏形整条不进、warn 与通告点名 `field: toolAxes` —— 改钉到 `test/task-mcp.test.ts`);" +
254
+ "本文件的 `migrationWindowOpen`(若再无别的读者)",
255
+ afterRemoval: "没有新的臂,接住它的是今天的读法(删码零行为变化:窗关那一刻起重读臂已经不生效):升级前存下的请求体里引擎判官判不过的 `toolAxes`,续跑重放时这一条 server 整条不进," +
256
+ "运维 warn `mcp_injection_dropped`(`reason: malformed_entry`、`field: toolAxes`)+ 用户面通告 `mcp.injection_dropped`,兄弟照挂;停驻在这台 server 工具审批上的任务" +
257
+ "批准后撞 `resume.tool_unavailable`(拒绝照常可出)—— 出路是拒绝那张卡,用改好的 `toolAxes` 重新提交。会撞上它的是活过窗的升级前停驻行:发布后才升级的部署、" +
258
+ "调长了 `APPROVAL_TERMINAL_BACKSTOP_MS` 的部署、滚动升级里旧副本在窗内新写的行(头注 M19 段)",
259
+ reportKeys: [],
260
+ },
244
261
  ];
245
262
  export function migrationWindowOpen(window, today) {
246
263
  return window.removableAfter === null || today <= window.removableAfter;
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import { spawn } from "node:child_process";
5
5
  import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
6
6
  import { signalNumber } from "./host-platform.js";
7
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
7
8
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
8
9
  import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
9
10
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -191,7 +192,7 @@ export class RemoteAdbExecutionEnv {
191
192
  w?.();
192
193
  };
193
194
  let lastChunk = Date.now();
194
- const idleMs = options?.readTimeoutMs;
195
+ const liveness = commandBoundsOf({ readTimeoutMs: options?.readTimeoutMs }, undefined).liveness;
195
196
  let paused = false;
196
197
  let onAbort;
197
198
  const teardown = () => {
@@ -262,7 +263,7 @@ export class RemoteAdbExecutionEnv {
262
263
  throw failure;
263
264
  if (finished)
264
265
  break;
265
- const waitMs = idleMs ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
266
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
266
267
  let waitTimer;
267
268
  try {
268
269
  await new Promise((r) => {
@@ -274,8 +275,8 @@ export class RemoteAdbExecutionEnv {
274
275
  if (waitTimer !== undefined)
275
276
  clearTimeout(waitTimer);
276
277
  }
277
- if (idleMs && !finished && Date.now() - lastChunk >= idleMs) {
278
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (device hang / disconnected)`);
278
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms) {
279
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
279
280
  }
280
281
  }
281
282
  while (queue.length)
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
7
7
  import { signalNumber } from "./host-platform.js";
8
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
8
9
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
9
10
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
10
11
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -188,7 +189,7 @@ export class RemoteLocalDockerExecutionEnv {
188
189
  let paused = false;
189
190
  let wake;
190
191
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
191
- const idleMs = options?.readTimeoutMs;
192
+ const liveness = commandBoundsOf({ readTimeoutMs: options?.readTimeoutMs }, undefined).liveness;
192
193
  const cap = options?.maxOutputBytes;
193
194
  let onAbort;
194
195
  const enqueue = (type, data) => {
@@ -270,7 +271,7 @@ export class RemoteLocalDockerExecutionEnv {
270
271
  throw failure;
271
272
  if (finished)
272
273
  break;
273
- const waitMs = idleMs != null ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
274
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
274
275
  let waitTimer;
275
276
  try {
276
277
  await new Promise((r) => { wake = r; waitTimer = setTimeout(r, waitMs); });
@@ -279,13 +280,13 @@ export class RemoteLocalDockerExecutionEnv {
279
280
  if (waitTimer !== undefined)
280
281
  clearTimeout(waitTimer);
281
282
  }
282
- if (idleMs != null && !finished && Date.now() - lastChunk >= idleMs) {
283
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms) {
283
284
  finished = true;
284
285
  try {
285
286
  child.kill("SIGKILL");
286
287
  }
287
288
  catch { }
288
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (command hang)`);
289
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
289
290
  }
290
291
  }
291
292
  while (queue.length)
@@ -3,6 +3,7 @@ import { Client } from "ssh2";
3
3
  import { createSshHostKeyVerifier } from "../ssh-host-key.js";
4
4
  import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode } from "./remote-shell.js";
5
5
  import { signalNumber } from "./host-platform.js";
6
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
6
7
  import { createPosixShellFs } from "./posix-shell-fs.js";
7
8
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
8
9
  import { fileErrorFromExec } from "./remote-env-file-error.js";
@@ -335,7 +336,7 @@ export class RemoteSshExecutionEnv {
335
336
  w?.();
336
337
  };
337
338
  let lastChunk = Date.now();
338
- const idleMs = options?.readTimeoutMs;
339
+ const liveness = commandBoundsOf({ readTimeoutMs: options?.readTimeoutMs }, undefined).liveness;
339
340
  let streamRef;
340
341
  let onAbort;
341
342
  let paused = false;
@@ -422,7 +423,7 @@ export class RemoteSshExecutionEnv {
422
423
  throw failure;
423
424
  if (finished)
424
425
  break;
425
- const waitMs = idleMs ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
426
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
426
427
  let waitTimer;
427
428
  try {
428
429
  await new Promise((r) => {
@@ -434,8 +435,8 @@ export class RemoteSshExecutionEnv {
434
435
  if (waitTimer !== undefined)
435
436
  clearTimeout(waitTimer);
436
437
  }
437
- if (idleMs && !finished && Date.now() - lastChunk >= idleMs)
438
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (provider hang / unreachable host)`);
438
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms)
439
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
439
440
  }
440
441
  while (queue.length)
441
442
  yield queue.shift();
@@ -175,6 +175,13 @@ export interface PrincipalEntitlementsClient {
175
175
  * FAIL-OPEN on error / older center without scenario keys / no principal (undefined = no governance, legacy passthrough).
176
176
  * Scenario switching is not a privilege-amplifying face; a center blip must not pin users to `default`. */
177
177
  scenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
+ /**
179
+ * {@link PrincipalEntitlementsClient.scenarioRuling} 的**只读**孪生(同 {@link PrincipalEntitlementsClient.peekRuntimeCaps} 的界):同一只缓存 / 同一条抓取 /
180
+ * 同一份 fail-open 判决(`undefined` = 无治理),唯一的差别是 center 硬失败时本腿铸的 fail-closed 降级项**只对读面自己算数**,提交腿不认、照常重抓。
181
+ * 读面(`GET /v1/capabilities/mcp?scenario=` 的场景门)问中心裁定时必须走这一只 —— 经 `scenarioRuling` 问,center 抖动的那一拍会替这个主体的**真提交**
182
+ * 留下 `{allowWorkflows:false, allowFork:false, autoMode:false}` 的短窗 deny(读面成了别人被拒的原因,与 `peekRuntimeCaps` 的病灶同一类)。
183
+ */
184
+ peekScenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
185
  /** (stage7 P5) The resolved per-principal execution-lane ruling for resolveSpec's
179
186
  * gateExecutionLane. FAIL-OPEN + audit on error (this phase is "honest-but-bypassable";
180
187
  * tightening to fail-closed is a future, separate decision) / older center without the execution key / no principal. */
@@ -167,6 +167,7 @@ export function createPrincipalEntitlementsClient(opts) {
167
167
  resolveRuntimeCaps: async (principal) => (principal ? (await load(principal)).caps : undefined),
168
168
  peekRuntimeCaps: async (principal) => (principal ? (await load(principal, false)).caps : undefined),
169
169
  scenarioRuling: async (principal) => (principal ? (await load(principal)).scenario : undefined),
170
+ peekScenarioRuling: async (principal) => (principal ? (await load(principal, false)).scenario : undefined),
170
171
  executionRuling: async (principal) => (principal ? (await load(principal)).execution : undefined),
171
172
  };
172
173
  }
@@ -14,10 +14,12 @@
14
14
  * main model; bounded + timed out).
15
15
  *
16
16
  * FAILURE POSTURE: everything is best-effort — a generation/store failure logs and leaves title null (the
17
- * list falls back to objectivePreview). An in-process pending set dedupes concurrent submits on the same
17
+ * list falls back to objectivePreview). The fail-open tag it leaves names the CAUSE FAMILY (one tag, one family):
18
+ * the llm face's failure CODE picks it (`failOpenTagFor`), never the error's wording. An in-process pending set dedupes concurrent submits on the same
18
19
  * session; the store's IS NULL guard closes the cross-replica race (double generation costs one extra cheap
19
20
  * call at worst, and exactly one title lands).
20
21
  */
22
+ import type { HookLlmFailureCode } from "./hooks/hook-runner.js";
21
23
  export interface SessionTitlerDeps {
22
24
  /** Secret scrub applied to the RAW llm text before sanitize/persist (workflow audit 2026-07-13: the
23
25
  * objective may carry a pasted secret and the model can echo it into the title — titles are persisted,
@@ -42,6 +44,7 @@ export interface SessionTitlerDeps {
42
44
  } | {
43
45
  ok: false;
44
46
  error: string;
47
+ code?: HookLlmFailureCode;
45
48
  }>;
46
49
  logger?: {
47
50
  debug?(msg: string, meta?: unknown): void;
@@ -8,6 +8,16 @@ const LLM_TIMEOUT_MS = 15_000;
8
8
  const NO_ROW_RETRY_MS = 20_000;
9
9
  const FAIL_CAUSE_KEY_CHARS = 80;
10
10
  const SEEN_CAP = 10_000;
11
+ function failOpenTagFor(code) {
12
+ switch (code) {
13
+ case "redirect_refused":
14
+ return "server.session-titler.redirect-refused";
15
+ case "no_content":
16
+ case "hard_timeout":
17
+ case undefined:
18
+ return "server.session-titler.gateway-unreachable";
19
+ }
20
+ }
11
21
  export function sanitizeTitle(raw) {
12
22
  const oneLine = raw.replace(/[\r\n]+/g, " ").replace(CONTROL_AND_BIDI_STRIP_RE, "").trim();
13
23
  const unquoted = oneLine.replace(/^["'「『]+/, "").replace(/["'」』]+$/, "").trim();
@@ -25,10 +35,10 @@ export function buildTitlePrompt(objective) {
25
35
  }
26
36
  export function createSessionTitler(deps) {
27
37
  const seen = new Set();
28
- const logFailure = (sessionId, error) => {
38
+ const logFailure = (sessionId, error, code) => {
29
39
  const safe = redactSecrets(error);
30
40
  deps.logger?.debug?.("session_title_failed", { sessionId, error: safe.slice(0, 200) });
31
- recordFailOpen("server.session-titler.gateway-unreachable", safe.slice(0, FAIL_CAUSE_KEY_CHARS));
41
+ recordFailOpen(failOpenTagFor(code), safe.slice(0, FAIL_CAUSE_KEY_CHARS));
32
42
  };
33
43
  const probeOrContinue = async (sessionId, continueWord) => deps
34
44
  .probeTitle(sessionId)
@@ -65,7 +75,7 @@ export function createSessionTitler(deps) {
65
75
  const r = await deps.llm({ prompt: buildTitlePrompt(objective), timeoutMs: LLM_TIMEOUT_MS, model });
66
76
  if (!r.ok) {
67
77
  deps.metrics?.inc("session_title_total", { outcome: "llm_failed" });
68
- logFailure(sessionId, r.error);
78
+ logFailure(sessionId, r.error, r.code);
69
79
  return;
70
80
  }
71
81
  const title = sanitizeTitle(deps.redact ? deps.redact(r.text) : r.text);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 一次性迁移 M19 —— 续跑重放腿上,升级前存下的请求体里引擎判官判不过的 `mcpServers[].toolAxes`,窗内按升级前的读法重读一次。
3
+ * 🔴 M19 · 窗口见 `plugins/one-time-migrations.ts` 的 ONE_TIME_MIGRATIONS 登记表(窗、删法、删后由谁接住只写在那一行)。
4
+ *
5
+ * ## 为什么要它(持久数据面)
6
+ * 请求腿读 `toolAxes` 的判官是引擎根导出的 `readMcpToolAxes`(整份一坏全拒、不默认)。升级前的请求腿读者是另一种读法:不是对象(含 `null`)⇒ 整键忽略、
7
+ * server 照挂;是对象 ⇒ 逐成员判形,过不了形的成员剔掉、好的留下。续跑重放腿重放的是**升级前存下的**请求体(`checkpoint_ctx` 里那一份),那时这台 server
8
+ * 是挂着的 —— 一条停驻在它某只工具审批上的任务,升级后若这台 server 整条不进,批准即撞引擎的 `resume.tool_unavailable`、检查点重开、每次重试重放同一份体
9
+ * ⇒ 永远批不过去。持久数据面不能「翻红」,所以给它一次性迁移:窗开着时,续跑重放腿把判官判不过的那一格按升级前的读法重读一次,产物**再交同一只判官**
10
+ * (不过 ⇒ 原臂:整条不进);真挂上的这类 server 由请求腿说一行运维 warn `mcp_tool_axes_read_as_before_upgrade`。窗关之后回到今天的读法,删码零行为变化。
11
+ *
12
+ * ## 可达面(结构上只在续跑重放腿)
13
+ * · 本模块的值只被 `boot/resolve-spec.ts` 引用一处,挂在 `opts.leg` 不是 `"fresh"` 的那一臂(续跑家族经 `http/resume-legs.ts` 的 `resolveResumeSpec`
14
+ * 递 `leg: "resume"`);新提交腿递 `"fresh"` 拿不到重读座,且在腿首已经被同一只判官 400 掉(`assertRequestMcpToolAxes`),读器根本见不到坏形;
15
+ * · 请求腿(`task-mcp.ts`)只 import 本模块的**型**:重读座是 `resolveRequestMcp` 的一个形参,没人递就没有;探测口(`validateRequestMcp`)没有这个形参;
16
+ * · 部署腿(`web-admin-client/skills-mcp.ts` 的 `resolveMcpServers`)是另一只铸 spec 处,不读本模块。
17
+ * 机器锚:`test/mcp-tool-axes-m19.test.ts` 的结构格。
18
+ */
19
+ import type { McpServerSpec } from "@sema-agent/core";
20
+ type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>;
21
+ /** 重读座:一份原始 `toolAxes` 声明 → 升级前的读法读出的那一份(`undefined` = 升级前读成「没声明」)。产物由调用方再交引擎判官。 */
22
+ export type StoredToolAxesRereadM19 = (declared: unknown) => ToolAxes | undefined;
23
+ /**
24
+ * 此刻(`today` = UTC 日历日 YYYY-MM-DD)M19 的窗开着 ⇒ 交回重读座;关了 ⇒ `undefined`(今天的读法)。窗只读登记表 M19 那一行
25
+ * (`migrationWindowOpen`,与 M10 的认领窗同一只判据)。登记表里没有 M19 ⇒ 响亮抛:重读臂活过了它的登记行,该随那一行一起删。
26
+ */
27
+ export declare function storedToolAxesRereadM19(today: string): StoredToolAxesRereadM19 | undefined;
28
+ export {};
29
+ //# sourceMappingURL=stored-request-tool-axes-m19.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./plugins/one-time-migrations.js";
2
+ import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
3
+ function readToolAxesAsServer7129M19(declared) {
4
+ if (!declared || typeof declared !== "object" || Array.isArray(declared))
5
+ return undefined;
6
+ const axes = {};
7
+ for (const [tool, ax] of Object.entries(declared)) {
8
+ if (!ax || typeof ax !== "object" || Array.isArray(ax))
9
+ continue;
10
+ const a = ax;
11
+ const entry = {
12
+ ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
13
+ ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
14
+ ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
15
+ };
16
+ if (Object.keys(entry).length > 0)
17
+ axes[tool] = entry;
18
+ }
19
+ return Object.keys(axes).length > 0 ? axes : undefined;
20
+ }
21
+ export function storedToolAxesRereadM19(today) {
22
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M19");
23
+ if (window === undefined)
24
+ throw new Error("stored-request-tool-axes-m19: the one-time migration registry has no M19 row (the replay-leg toolAxes re-read window) — the re-read arm outlived its registry row; delete it with that row");
25
+ return migrationWindowOpen(window, today) ? readToolAxesAsServer7129M19 : undefined;
26
+ }
27
+ //# sourceMappingURL=stored-request-tool-axes-m19.js.map
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { type CompliancePosture, type EngineNotice, type LockedKey, type McpInjectionDropReason, type McpServerSpec } from "@sema-agent/core";
26
26
  import { type McpIdentityHeadersIssue, type RequestMcpServerSpec } from "./mcp-assertion-header.js";
27
+ import type { StoredToolAxesRereadM19 } from "./stored-request-tool-axes-m19.js";
27
28
  /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */
28
29
  export declare const MAX_REQUEST_MCP_SERVERS = 32;
29
30
  /**
@@ -114,6 +115,7 @@ export interface McpInjectionDropSeat {
114
115
  * Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the
115
116
  * valid subset + the names of dropped (malformed/over-cap) entries + [ref]'s `badContentOrigin`
116
117
  * (**降级**掉的内容出处声明,与 `dropped` 是两回事:那条 server **留着**,丢的只是它的声明)。
118
+ * 这一口没有 M19 的重读座(探测口与单测读到的 `toolAxes` 恒是判官的读数);重读只在续跑重放腿经 {@link resolveRequestMcp} 发生。
117
119
  */
118
120
  export declare function validateRequestMcp(raw: unknown): ValidatedRequestMcp | null;
119
121
  type ValidatedRequestMcp = {
@@ -208,6 +210,32 @@ export declare function requestEntryIdentityRefusal(entry: unknown): {
208
210
  errorCode: "request.body_shape" | "request.field_invalid";
209
211
  message: string;
210
212
  } | undefined;
213
+ /**
214
+ * S-1604 —— `mcpServers[].toolAxes` 的**值域门**(fresh 腿限定,同步 400):判官是引擎根导出的 `readMcpToolAxes`(与部署腿铸 spec 处同一只),
215
+ * 判出 `ok:false` ⇒ `400 request.field_invalid`,点名 `mcpServers[i].toolAxes`、server 名与引擎的原因句(点名工具与成员,不带值)。
216
+ *
217
+ * ## 为什么拒而不是剔
218
+ * `toolAxes` 是调用方对这台 server 每只工具的**双向**风险轴声明(能压低一只工具的效应让审批门放行,也能抬高)。静默剔掉坏成员 = 调用方拿到 2xx、
219
+ * 以为整份声明生效,实际只有另一半在力 —— 一份没有生效的信任根声明。引擎的读法本就是「整份一坏全拒、好的那几只也不留、什么都不默认」,
220
+ * 请求腿修前自铸的读者与它相反;从此只有一只判官,两条腿同读。
221
+ *
222
+ * ## 位置与 leg 分流(与 {@link assertRequestMcpContentOrigin} / {@link assertRequestMcpIdentityHeaders} 同款理由,不发明第二套)
223
+ * 排在腿首、任何副作用之前;**只在 fresh 腿**:`checkpoint_ctx` 存的是原始 `TaskRequestBody`,耐久 resume 逐字重放它,从这里逃出去的 `HttpError`
224
+ * 会被折成 `409 resume_blocked_by_policy`,把一条升级前 park 的任务永久砖死。resume 腿走读器({@link normalizeServer}):同一只判官判出坏形 ⇒ 这一条 server
225
+ * 不进 + `malformed_entry` 点名(运维 warn + 用户面通告,不抛);一次性迁移 M19 的窗开着时先按升级前读法重读一次(见那只读器的注)。
226
+ * 判据是纯值域、与租户车道无关(多租户腿本来就忽略整只数组,告诉调用方「你这条写错了」只多不少)。
227
+ * 探测口读同一只 {@link requestEntryToolAxesRefusal},同一句拒。
228
+ */
229
+ export declare function assertRequestMcpToolAxes(bodyMcp: unknown): void;
230
+ /**
231
+ * 一条**原始**请求条目上 `toolAxes` 的拒面(码 + 句子);没有问题 / 条目不是对象 / 没写 `toolAxes` ⇒ `undefined`。提交腿的门({@link assertRequestMcpToolAxes})与探测口
232
+ * (`mcp-probe.ts` 的 `entryValueIssue`)两扇拒绝门同读这一只,所以两处是**同一句**。判官是引擎的 `readMcpToolAxes`,句子里的原因句逐字取它的 `problem`
233
+ * (只剥控制与双向字符:工具名是调用方写的);不回显值 —— 引擎的句子点名工具与成员、不带值。`at` = 条目的键路径(`mcpServers[3]`)。
234
+ */
235
+ export declare function requestEntryToolAxesRefusal(entry: unknown, at: string): {
236
+ errorCode: "request.field_invalid";
237
+ message: string;
238
+ } | undefined;
211
239
  /**
212
240
  * The ONE predicate for "this MCP server name belongs to the deployment": the scenario baseline (what mounted for
213
241
  * this scenario) ∪ every name the deployment DECLARED (`CenterMcp.reservedNames` — mounted or not, any scenario).
@@ -247,6 +275,9 @@ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined,
247
275
  /** L-167:本条腿的**用户面**投递口。`sessionId` = 通告的路由半场(缺席 ⇒ 一条都不铸,见
248
276
  * {@link mintInjectionDropNotices});`emit` 收下 core 铸好的通告,由调用腿决定投到哪(见 `boot/resolve-spec.ts`)。
249
277
  * 整段缺席(桩、单测、非 HTTP 腿)⇒ 只走日志终点,行为逐字与本件之前相同。 */
250
- notice?: McpInjectionDropSeat): McpServerSpec[] | undefined;
278
+ notice?: McpInjectionDropSeat,
279
+ /** 一次性迁移 M19 的重读座(窗口见 `plugins/one-time-migrations.ts` 登记表):只在续跑重放腿、只在窗开着时由 `boot/resolve-spec.ts` 递进来;
280
+ * 缺席(新提交腿 / 窗关 / 桩)⇒ 判官判不过的 `toolAxes` 照原臂整条不进。 */
281
+ storedToolAxes?: StoredToolAxesRereadM19): McpServerSpec[] | undefined;
251
282
  export {};
252
283
  //# sourceMappingURL=task-mcp.d.ts.map
package/dist/task-mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mcpInjectionDroppedNotice, resolveComplianceDenies } from "@sema-agent/core";
1
+ import { mcpInjectionDroppedNotice, readMcpToolAxes, resolveComplianceDenies } from "@sema-agent/core";
2
2
  import { isUsableWireName } from "./wire-name-bound.js";
3
3
  import { HttpError, createCodedHttpError } from "./security.js";
4
4
  import { GOVERNANCE_HTTP_STATUS } from "./governance-http-status.js";
@@ -6,8 +6,8 @@ import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } f
6
6
  import { readMcpSourceLabel } from "./mcp-source-label.js";
7
7
  import { declaresMcpAssertionHeader, describeMcpAssertionHeaderNotAString, describeMcpIdentityHeadersConflict, mcpIdentityHeadersIssue, noteMcpAssertionHeaderIgnored, noteMcpAssertionHeaderNotSent, readMcpIdentityHeaders, toCoreMcpServerSpec } from "./mcp-assertion-header.js";
8
8
  import { echoKeyName } from "./request-key-closure.js";
9
+ import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
9
10
  import { readMcpToolFaces } from "./mcp-tool-faces.js";
10
- import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
11
11
  export const MAX_REQUEST_MCP_SERVERS = 32;
12
12
  export const MAX_REQUEST_MCP_DROP_NAMES = MAX_REQUEST_MCP_SERVERS;
13
13
  export const MCP_SERVER_ENTRY_KEYS = ["allowTools", "contentOrigin", "elicitation", "name", "source", "toolAxes", "toolFaces", "transport"];
@@ -34,7 +34,7 @@ function declaredName(e) {
34
34
  ? String(e.name)
35
35
  : MALFORMED_NAME;
36
36
  }
37
- function normalizeServer(e, noteBadOrigin) {
37
+ function normalizeServer(e, noteBadOrigin, storedToolAxes) {
38
38
  if (!e || typeof e !== "object")
39
39
  return bad("entry");
40
40
  const o = e;
@@ -76,23 +76,20 @@ function normalizeServer(e, noteBadOrigin) {
76
76
  }
77
77
  if (typeof o.elicitation === "boolean")
78
78
  spec.elicitation = o.elicitation;
79
- if (o.toolAxes && typeof o.toolAxes === "object" && !Array.isArray(o.toolAxes)) {
80
- const axes = {};
81
- for (const [tool, ax] of Object.entries(o.toolAxes)) {
82
- if (!ax || typeof ax !== "object" || Array.isArray(ax))
83
- continue;
84
- const a = ax;
85
- const entry = {
86
- ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
87
- ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
88
- ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
89
- };
90
- if (Object.keys(entry).length > 0)
91
- axes[tool] = entry;
79
+ const axes = readMcpToolAxes(o.toolAxes);
80
+ let judged = axes;
81
+ let toolAxesReread;
82
+ if (!axes.ok && storedToolAxes !== undefined) {
83
+ const again = readMcpToolAxes(storedToolAxes(o.toolAxes));
84
+ if (again.ok) {
85
+ judged = again;
86
+ toolAxesReread = axes.problem.replace(CONTROL_AND_BIDI_STRIP_RE, "");
92
87
  }
93
- if (Object.keys(axes).length > 0)
94
- spec.toolAxes = axes;
95
88
  }
89
+ if (!judged.ok)
90
+ return bad("toolAxes");
91
+ if (judged.toolAxes !== undefined)
92
+ spec.toolAxes = judged.toolAxes;
96
93
  if (o.contentOrigin !== undefined) {
97
94
  if (isContentOriginWord(o.contentOrigin))
98
95
  spec.contentOrigin = o.contentOrigin;
@@ -105,29 +102,32 @@ function normalizeServer(e, noteBadOrigin) {
105
102
  return bad("toolFaces");
106
103
  if (faces.faces !== undefined)
107
104
  spec.toolFaces = faces.faces;
108
- return { ok: true, spec };
105
+ return { ok: true, spec, ...(toolAxesReread !== undefined ? { toolAxesReread } : {}) };
109
106
  }
110
107
  export function validateRequestMcp(raw) {
111
- if (raw === undefined || raw === null)
112
- return validateRequestMcpEntries([]);
113
- if (!Array.isArray(raw))
108
+ if (raw !== undefined && raw !== null && !Array.isArray(raw))
114
109
  return null;
115
- return validateRequestMcpEntries(raw);
110
+ const { ok, dropped, droppedTotal, badContentOrigin } = validateRequestMcpEntries(Array.isArray(raw) ? raw : []);
111
+ return { ok, dropped, droppedTotal, badContentOrigin };
116
112
  }
117
- function validateRequestMcpEntries(raw) {
113
+ function validateRequestMcpEntries(raw, storedToolAxes) {
118
114
  const ok = [];
119
115
  const dropped = [];
120
116
  const badContentOrigin = [];
117
+ const toolAxesReread = [];
121
118
  for (const e of raw.slice(0, MAX_REQUEST_MCP_SERVERS)) {
122
- const v = normalizeServer(e, (server, value) => badContentOrigin.push({ server, value: describeBadContentOrigin(value) }));
123
- if (v.ok)
119
+ const v = normalizeServer(e, (server, value) => badContentOrigin.push({ server, value: describeBadContentOrigin(value) }), storedToolAxes);
120
+ if (v.ok) {
124
121
  ok.push(v.spec);
122
+ if (v.toolAxesReread !== undefined)
123
+ toolAxesReread.push({ server: v.spec.name, problem: v.toolAxesReread });
124
+ }
125
125
  else
126
126
  dropped.push({ server: declaredName(e), reason: "malformed_entry", field: v.field });
127
127
  }
128
128
  for (const e of raw.slice(MAX_REQUEST_MCP_SERVERS, MAX_REQUEST_MCP_SERVERS + MAX_REQUEST_MCP_DROP_NAMES))
129
129
  dropped.push({ server: declaredName(e), reason: "over_cap" });
130
- return { ok, dropped, droppedTotal: Math.max(0, raw.length - ok.length), badContentOrigin };
130
+ return { ok, dropped, droppedTotal: Math.max(0, raw.length - ok.length), badContentOrigin, toolAxesReread };
131
131
  }
132
132
  export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
133
133
  if (!lockedKeys.has("mcp"))
@@ -180,6 +180,29 @@ export function requestEntryIdentityRefusal(entry) {
180
180
  const value = (entry.transport).assertionHeader;
181
181
  return { errorCode: "request.field_invalid", message: describeMcpAssertionHeaderNotAString(name, value) };
182
182
  }
183
+ export function assertRequestMcpToolAxes(bodyMcp) {
184
+ if (!Array.isArray(bodyMcp))
185
+ return;
186
+ for (let i = 0; i < bodyMcp.length; i++) {
187
+ const refusal = requestEntryToolAxesRefusal(bodyMcp[i], `mcpServers[${i}]`);
188
+ if (refusal !== undefined)
189
+ throw createCodedHttpError(400, refusal.errorCode, refusal.message);
190
+ }
191
+ }
192
+ export function requestEntryToolAxesRefusal(entry, at) {
193
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
194
+ return undefined;
195
+ const read = readMcpToolAxes(entry.toolAxes);
196
+ if (read.ok)
197
+ return undefined;
198
+ const server = echoKeyName("", declaredName(entry));
199
+ return {
200
+ errorCode: "request.field_invalid",
201
+ message: `${at}.toolAxes (server "${server}") is not a usable declaration — ${read.problem.replace(CONTROL_AND_BIDI_STRIP_RE, "")}. ` +
202
+ `The whole declaration is refused: the tools that were fine are not kept and nothing is defaulted, because a toolAxes declaration is your own risk-axis judgement for this server's tools ` +
203
+ `(it can move a tool down or up), and a half-applied one would silently differ from what you declared. Fix the entry, or omit the key to declare nothing.`,
204
+ };
205
+ }
183
206
  export function deploymentClosedMcpNames(baseline, reservedNames) {
184
207
  return new Set([...(baseline ?? []).map((s) => s.name), ...reservedNames]);
185
208
  }
@@ -190,20 +213,20 @@ export function mergeRequestMcp(baseline, gated, reservedNames) {
190
213
  const add = gated.filter((s) => !closed.has(s.name));
191
214
  return add.length === 0 ? baseline : [...(baseline ?? []), ...add];
192
215
  }
193
- export function resolveRequestMcp(baseline, bodyMcp, config, logger, notice) {
216
+ export function resolveRequestMcp(baseline, bodyMcp, config, logger, notice, storedToolAxes) {
194
217
  if (bodyMcp === undefined || bodyMcp === null)
195
218
  return baseline;
196
- const { mcp, drops } = classifyRequestMcp(baseline, bodyMcp, config, logger);
219
+ const { mcp, drops } = classifyRequestMcp(baseline, bodyMcp, config, logger, storedToolAxes);
197
220
  mintInjectionDropNotices(drops, notice);
198
221
  return mcp;
199
222
  }
200
- function classifyRequestMcp(baseline, bodyMcp, config, logger) {
223
+ function classifyRequestMcp(baseline, bodyMcp, config, logger, storedToolAxes) {
201
224
  if (bodyMcp !== undefined && bodyMcp !== null && !Array.isArray(bodyMcp)) {
202
225
  logger?.warn("mcp_injection_ignored", { reason: "mcpServers not an array" });
203
226
  return { mcp: baseline, drops: [] };
204
227
  }
205
228
  const entries = Array.isArray(bodyMcp) ? bodyMcp : [];
206
- const outcome = mcpInjectionHonored(config) ? classifyHonored(baseline, entries, config, logger) : classifyGateClosed(baseline, entries);
229
+ const outcome = mcpInjectionHonored(config) ? classifyHonored(baseline, entries, config, logger, storedToolAxes) : classifyGateClosed(baseline, entries);
207
230
  if (outcome.drops.length > 0)
208
231
  logger?.warn("mcp_injection_dropped", { dropped: outcome.drops, droppedTotal: outcome.droppedTotal });
209
232
  return { mcp: outcome.mcp, drops: outcome.drops };
@@ -215,8 +238,8 @@ function classifyGateClosed(baseline, entries) {
215
238
  droppedTotal: entries.length,
216
239
  };
217
240
  }
218
- function classifyHonored(baseline, entries, config, logger) {
219
- const v = validateRequestMcpEntries(entries);
241
+ function classifyHonored(baseline, entries, config, logger, storedToolAxes) {
242
+ const v = validateRequestMcpEntries(entries, storedToolAxes);
220
243
  if (v.badContentOrigin.length > 0) {
221
244
  logger?.warn("mcp_content_class_dropped", {
222
245
  entries: v.badContentOrigin,
@@ -236,6 +259,16 @@ function classifyHonored(baseline, entries, config, logger) {
236
259
  if (issue !== undefined && mounted.has(declaredName(e)))
237
260
  noteMcpAssertionHeaderIgnored(logger, declaredName(e), issue);
238
261
  }
262
+ for (const r of v.toolAxesReread) {
263
+ if (!mounted.has(r.server))
264
+ continue;
265
+ logger?.warn("mcp_tool_axes_read_as_before_upgrade", {
266
+ server: r.server,
267
+ migration: "M19",
268
+ problem: r.problem,
269
+ note: "this replayed request was stored before the upgrade and its toolAxes declaration does not pass the engine's reader; while one-time migration M19 is open it is read the way the pre-upgrade server read it (members that did not fit are dropped, a declaration that is not an object counts as none) and the server stays mounted — once the window closes the same request leaves this server unmounted",
270
+ });
271
+ }
239
272
  return {
240
273
  mcp: mergeRequestMcp(baseline, v.ok.map(toCoreMcpServerSpec), reservedNames),
241
274
  drops: [...v.dropped, ...blocked.map((server) => ({ server, reason: "name_reserved_by_deployment" }))],
@@ -1,30 +1,27 @@
1
1
  /**
2
- * S-249(DT-13)—— per-tool **风险轴覆写**的值域词表与谓词,**两面共用一份**。
2
+ * S-249(DT-13)—— per-tool **风险轴覆写**的值域词表与谓词(读者:**A2A 请求腿**;以及一次性迁移 M19 的升级前读法 `stored-request-tool-axes-m19.ts`,随 M19 的登记行退役)。
3
3
  *
4
- * ## 病(修前)
5
- * `task-mcp.ts` 与 `task-a2a.ts` 各自在请求腿上逐键校验同一个三成员结构
6
- * (`effect` / `egress` / `irreversibility`),而两个**闭集词表**是**手抄的字面串**:四份字面量
7
- * (两文件 × 两个词轴),没有一份钉在 core 的型上。后果不是「今天判错了」,而是**判据比声明窄**这件事
8
- * 没有任何门看得见 —— core 哪天给 `ToolEffect` 加一个词(或把 `irreversibility` 的两词改成三词,它在
9
- * `AskOrigin` 那一面**已经**是 `never|maybe|always` 三词了),两条请求腿会把那个**合法**的新词
10
- * **静默剔除**(MCP 腿)或**整条 server 判假**(A2A 腿),而 tsc 一声不吭。
4
+ * ## 历史与现状
5
+ * 修前 `task-mcp.ts` 与 `task-a2a.ts` 各自在请求腿上逐键校验同一个三成员结构(`effect` / `egress` / `irreversibility`),两个**闭集词表**是手抄的字面串,
6
+ * 没有一份钉在 core 的型上;S-249 把词表收到本文件并**两向钉死在 core 的型上**(core 加词 ⇒ 正向门红;本表留了 core 已没有的词 ⇒ 反向门红),两条腿读同一只谓词。
11
7
  *
12
- * ## 修法
13
- * 词表留在本仓(运行期要它),但**两向钉死在 core 的型上**:core 加词 ⇒ 正向门红;本表留了一个 core
14
- * 已经没有的词 ⇒ 反向门红。两个面共用**同一只**谓词,于是「什么算一个合法的轴值」在本仓只有一个答案。
8
+ * 之后 MCP 面的 `toolAxes` 判官整个换给了**引擎**:core 7.51.2 起根导出 `readMcpToolAxes`(整份声明一坏全拒、不默认),部署腿(`web-admin-client/skills-mcp.ts`)
9
+ * 与请求腿(`task-mcp.ts`)都问它,本仓不再有 MCP 面的自铸读者、也不再 import 本文件的谓词。**A2A 面引擎没有对应的判官**(core 暂无 `readA2aToolAxes`),
10
+ * 所以 A2A 请求腿(`task-a2a.ts`)仍用本文件的谓词;引擎一旦给 A2A 也立一只判官,本文件随之退役。另一个读者是 M19 的迁移读者:续跑重放腿把升级前存下的
11
+ * `mcpServers[].toolAxes` 按升级前那只读者的读法重读一次(产物再交引擎的判官),那段读法当年就读本文件的谓词,逐字搬回;它不是 MCP 面的第二只判官。
15
12
  *
16
- * 🔴 **只管值域,不管处置**:两条腿对坏值的处置**刻意不同**(MCP = 丢这一键、留 server;A2A = 整条拒),
17
- * 判据与理由各写在各自的调用点。合并处置会抹掉那条真实的分歧(MCP 的 `toolAxes` 有 server-hint 腿可折,
18
- * A2A 收下的就是最终值),而分歧本身是裁定过的。
13
+ * 下面的同型钉(A2A 的轴型与 MCP 的轴型逐字同声明)仍有意义:A2A 的词表跟着引擎 MCP 面的轴型走,哪一边加词、改词,另一边在这里当场红。
14
+ *
15
+ * 🔴 **只管值域,不管处置**:A2A 腿对坏值的处置是整条拒(MCP 面是引擎判官的「整份全拒」;两面方向一致),M19 的迁移读者剔坏成员(升级前的读法),判据与理由写在各自的调用点。
19
16
  */
20
17
  import type { McpServerSpec, ToolEffect } from "@sema-agent/core";
21
- /** 一条 per-tool 轴覆写的形。取 MCP 那一面作代表 —— 下面的同型钉证明两面逐字同声明。 */
18
+ /** 一条 per-tool 轴覆写的形。取 MCP 那一面作代表 —— 下面的同型钉证明 A2A 面与它逐字同声明。 */
22
19
  export type ToolAxisEntry = NonNullable<McpServerSpec["toolAxes"]>[string];
23
20
  /** 轴覆写上那个**两词**的不可逆档(⚠️ 与 `AskOrigin` 面的**三词** `never|maybe|always` 不是同一个词表:
24
21
  * 覆写面只收两极,`maybe` 是引擎按工具自己的声明定的,调用方说不出口)。 */
25
22
  export type ToolAxisIrreversibility = NonNullable<ToolAxisEntry["irreversibility"]>;
26
- /** 值是不是一个合法的 `effect` 词(两条请求腿共用)。 */
23
+ /** 值是不是一个合法的 `effect` 词(A2A 请求腿与 M19 的迁移读者读它)。 */
27
24
  export declare function isToolEffect(v: unknown): v is ToolEffect;
28
- /** 值是不是一个合法的**覆写面** `irreversibility` 词(两条请求腿共用)。 */
25
+ /** 值是不是一个合法的**覆写面** `irreversibility` 词(A2A 请求腿与 M19 的迁移读者读它)。 */
29
26
  export declare function isToolAxisIrreversibility(v: unknown): v is ToolAxisIrreversibility;
30
27
  //# sourceMappingURL=tool-axis-words.d.ts.map
@@ -27,7 +27,7 @@ export declare function applySkillsManifest(baseline: LoadedSkill[], manifest: {
27
27
  * Resolve center MCP servers (`/effective.mcp`) into core `McpServerSpec`s + the scenarios each applies to.
28
28
  * env-NAME refs → real values from THIS service's env (secret boundary unchanged); a server with any missing
29
29
  * ref is skipped (`resolveRefs` → null). `enabled:false` skipped. A server whose `toolFaces` / `toolAxes` declaration is
30
- * malformed is skipped whole (both are trust-root declarations — see `mcp-tool-faces.ts` / `mcp-tool-axes.ts`). Restart-to-apply
30
+ * malformed is skipped whole (both are trust-root declarations — see `mcp-tool-faces.ts` / the engine's `readMcpToolAxes`). Restart-to-apply
31
31
  * (filtered per scenario in `resolveSpec` via `mcpForScenario`).
32
32
  */
33
33
  export declare function resolveMcpServers(mcp: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.129.1",
3
+ "version": "7.130.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",