@sema-agent/server 7.115.0 → 7.116.1

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.
Files changed (116) hide show
  1. package/MIGRATION.md +167 -4
  2. package/USAGE.md +6 -3
  3. package/dist/approval-ask-audit-store.d.ts +25 -6
  4. package/dist/approval-ask-audit-store.js +6 -1
  5. package/dist/approval-card.d.ts +8 -4
  6. package/dist/approval-card.js +2 -0
  7. package/dist/approval-crash-convergence.d.ts +60 -0
  8. package/dist/approval-crash-convergence.js +75 -0
  9. package/dist/approval-deny-reasons.d.ts +13 -0
  10. package/dist/approval-deny-reasons.js +2 -0
  11. package/dist/approval.js +2 -1
  12. package/dist/boot/coordinators.js +0 -12
  13. package/dist/boot/execution-env.js +2 -1
  14. package/dist/boot/resolve-spec.js +1 -0
  15. package/dist/boot/stage-07-capability-layer.js +21 -15
  16. package/dist/boot/stores.js +4 -4
  17. package/dist/capabilities/hands-lane.d.ts +0 -23
  18. package/dist/capabilities/hands-lane.js +3 -2
  19. package/dist/capabilities/send-user-file-tool.d.ts +10 -22
  20. package/dist/capabilities/send-user-file-tool.js +1 -8
  21. package/dist/capability-facts.d.ts +296 -0
  22. package/dist/capability-facts.js +354 -0
  23. package/dist/config-center/apply-effective.js +1 -1
  24. package/dist/config.d.ts +16 -13
  25. package/dist/config.js +12 -12
  26. package/dist/http/admission.d.ts +6 -0
  27. package/dist/http/admission.js +22 -5
  28. package/dist/http/principal-gate.d.ts +3 -0
  29. package/dist/http/principal-gate.js +3 -0
  30. package/dist/http/resume-legs.js +10 -1
  31. package/dist/http/retired-skill-manifest.d.ts +23 -0
  32. package/dist/http/retired-skill-manifest.js +22 -0
  33. package/dist/http/route-table.d.ts +3 -2
  34. package/dist/http/route-table.js +5 -5
  35. package/dist/http/routes/a2a-serve.js +14 -21
  36. package/dist/http/routes/admin-config-refresh.js +7 -2
  37. package/dist/http/routes/admin-drain.js +7 -2
  38. package/dist/http/routes/admin-memory-optout.js +20 -6
  39. package/dist/http/routes/adoption.js +19 -5
  40. package/dist/http/routes/agents-roster.js +4 -2
  41. package/dist/http/routes/approvals-assistant.js +41 -18
  42. package/dist/http/routes/background.d.ts +0 -2
  43. package/dist/http/routes/background.js +4 -5
  44. package/dist/http/routes/capabilities.d.ts +2 -1
  45. package/dist/http/routes/capabilities.js +62 -65
  46. package/dist/http/routes/config-catalog.d.ts +4 -5
  47. package/dist/http/routes/config-catalog.js +16 -2
  48. package/dist/http/routes/devices.js +17 -11
  49. package/dist/http/routes/diagnostics.js +6 -1
  50. package/dist/http/routes/fleet.js +4 -2
  51. package/dist/http/routes/images.js +30 -3
  52. package/dist/http/routes/leader.js +7 -1
  53. package/dist/http/routes/mcp-reconnect.js +17 -10
  54. package/dist/http/routes/memory-bundle.js +20 -6
  55. package/dist/http/routes/memory-compliance.js +19 -5
  56. package/dist/http/routes/memory-consolidation.d.ts +9 -12
  57. package/dist/http/routes/memory-consolidation.js +17 -3
  58. package/dist/http/routes/memory-origin.js +19 -5
  59. package/dist/http/routes/notify-wake.js +5 -3
  60. package/dist/http/routes/observability.js +19 -5
  61. package/dist/http/routes/retention-ops.js +6 -1
  62. package/dist/http/routes/rules.js +49 -21
  63. package/dist/http/routes/run-memory-capture.js +5 -2
  64. package/dist/http/routes/runs.js +90 -60
  65. package/dist/http/routes/session-sync.js +64 -53
  66. package/dist/http/routes/sessions-list.js +8 -5
  67. package/dist/http/routes/sessions.js +39 -24
  68. package/dist/http/routes/shared-memory.js +5 -3
  69. package/dist/http/routes/tasks.js +2 -1
  70. package/dist/http/routes/trace-usage.js +18 -11
  71. package/dist/http/routes/workflows.js +36 -13
  72. package/dist/http/server.js +4 -1
  73. package/dist/http/wire-types.d.ts +6 -0
  74. package/dist/namespaced-rule-name.d.ts +54 -0
  75. package/dist/namespaced-rule-name.js +33 -0
  76. package/dist/observability/corrupt-read-seat.d.ts +4 -3
  77. package/dist/observability/corrupt-read-seat.js +4 -3
  78. package/dist/observability/fail-open.d.ts +5 -1
  79. package/dist/observability/fail-open.js +5 -1
  80. package/dist/observability/permission-rule-events.d.ts +3 -0
  81. package/dist/observability/permission-rule-events.js +4 -0
  82. package/dist/plugins/file-run-store.d.ts +7 -1
  83. package/dist/plugins/file-run-store.js +20 -7
  84. package/dist/plugins/memory-run-store.js +2 -1
  85. package/dist/plugins/one-time-migrations.d.ts +4 -2
  86. package/dist/plugins/one-time-migrations.js +26 -3
  87. package/dist/plugins/pg-session-storage.js +1 -1
  88. package/dist/plugins/run-store-sql.js +4 -4
  89. package/dist/plugins/session-policy-store-sql.js +4 -2
  90. package/dist/plugins/store-backend.d.ts +6 -1
  91. package/dist/plugins/store-backend.js +2 -1
  92. package/dist/plugins/store-contracts.d.ts +27 -8
  93. package/dist/plugins/store-contracts.js +12 -3
  94. package/dist/plugins/tidb-session-store.js +1 -1
  95. package/dist/request-key-closure.js +1 -1
  96. package/dist/run-local.d.ts +37 -3
  97. package/dist/run-local.js +48 -5
  98. package/dist/runs.d.ts +0 -58
  99. package/dist/runs.js +15 -20
  100. package/dist/session-policy-wire.d.ts +40 -60
  101. package/dist/session-policy-wire.js +60 -32
  102. package/dist/spec-fields.d.ts +32 -0
  103. package/dist/spec-fields.js +31 -16
  104. package/dist/standing-answer-grant.d.ts +8 -0
  105. package/dist/standing-answer-grant.js +5 -0
  106. package/dist/task-settings.d.ts +13 -3
  107. package/dist/tool-approval.d.ts +16 -0
  108. package/dist/tool-approval.js +29 -7
  109. package/dist/trace/core-keyset-guard.d.ts +4 -4
  110. package/dist/trace/self-minted-wire.d.ts +42 -0
  111. package/dist/trace/self-minted-wire.js +13 -0
  112. package/dist/user-input-ledger.d.ts +8 -5
  113. package/package.json +5 -3
  114. package/scripts/drain-check-local.mjs +115 -25
  115. package/scripts/drain-check.mysql.sql +67 -0
  116. package/scripts/drain-check.pg.sql +71 -0
@@ -481,7 +481,7 @@ function stageApprovalRequire(raw) {
481
481
  return {
482
482
  ok: false,
483
483
  detail: unmatchable.message,
484
- reason: `published governance.approvalRequire has ${unmatchable.names.length} entr${unmatchable.names.length === 1 ? "y" : "ies"} that match no live tool (retired name / incomplete MCP name / CC-form rule) — the offending entries are named in the sema_registry_approval_require_invalid log line`,
484
+ reason: `published governance.approvalRequire has ${unmatchable.names.length} entr${unmatchable.names.length === 1 ? "y" : "ies"} that match no live tool (retired name / incomplete protocol name / server segment the engine never mints / CC-form rule) — the offending entries are named in the sema_registry_approval_require_invalid log line`,
485
485
  names: unmatchable.names,
486
486
  kept: KEPT_WOULD_MATCH_NOTHING,
487
487
  };
package/dist/config.d.ts CHANGED
@@ -293,11 +293,14 @@ export declare function isRosterGateArmed(configCenter: ServiceConfig["configCen
293
293
  * 名单比的是**整串工具名**,带括号那串不是任何活工具的名字 ⇒ 永不匹配。指引=按命令名走
294
294
  * `runtime.commandPolicy`,整条 shell 进门走 `MANUAL_MODE_SHELL_GATE=always`。判别式见
295
295
  * {@link findCcRuleFormNames}(它同时是 `allow` 腿的**唯一**判据,见下方例外说明)。
296
- * - 退役名(`bash`/`Task`/`KillShell`…):core `RETIRED_TOOL_NAMES` 静态表,指引=现役名。
297
- * - pre-prefix 短名(`figma__x`):v4 自动加前缀的折叠面已删,实挂名恒带 `mcp__`,指引=补全前缀。
298
- * - 不完整 MCP 名(`mcp__`、`mcp__figma`):前缀对但缺段——MCP 实挂名恒是 `mcp__<server>__<tool>` 三段形,
299
- * 少一段同样永不匹配。旧判据「含 `__` 且 protocolOf 认不出」放过了这一族(前缀恰好认得出),从另一侧
300
- * 漏出去,失守方向与 `figma__x` 完全相同。
296
+ * - 退役名(`bash`/`Task`/`KillShell`…)与 pre-prefix 短名(`figma__x`):S-1047(core 7.41.0 #1156)起判据是 core 导出的
297
+ * **那一只** `judgeRuleToolName`(准备阶段、两只会话规则店、本仓会话规则写口同调),本函数零手写臂;指引 = core 表给的现役指引 /
298
+ * 补全协议前缀。
299
+ * - 够不着挂载工具的协议名:缺段(`mcp__`、`mcp__srv__`、`mcp____x`)与**对端段铸不出**(`mcp__github.com`、`mcp__github.com__*`、
300
+ * 超长 / 归一后带连串下划线的服务器段;服务器段带通配的 `mcp__*` 单列判词 `wildcard_peer`)—— 前缀对,但引擎铸注册名时归一对端段,而规则对对端段逐字比,永不匹配。
301
+ * 判官与句子是 `namespaced-rule-name.ts` 的那**一只**(判据 = core `mintNamespacePrefix` 的自铸对照;会话规则写口两腿与 SQL 店写口同调),
302
+ * 本文件零手写臂。修前(7.116.0 之前)这里只有缺段一臂外加一条只排 `*` 的特例(`coversAServer`),对端段带点号 / 超长的覆盖形在每个面都照收。
303
+ * ⚠️ 两段覆盖形 `mcp__<server>`(服务器段是自铸形)够得着(S-1047 收掉的分叉,见下)。
301
304
  * - **core 的文法编不出来的串**(名字里带反斜杠、单条 >512 字符、空串):判词取自 core 自己的
302
305
  * `validatePermissionRules`,见 {@link uncompilableRuleGuidance}。[ref]①([ref])补齐 ——
303
306
  * 此前只有 `ask` 腿(经 `createPermissionRulePolicy` 的 throw)拒得出这一族,`deny` 静默 no-op。
@@ -313,12 +316,12 @@ export declare function isRosterGateArmed(configCenter: ServiceConfig["configCen
313
316
  * `<ns>__<server>__*` / `<ns>__<server>__<glob>` 在**每一条**腿上都是**真判**,不再是
314
317
  * 「放行进 no-op」—— 而那正是 [ref] 病形(`APPROVAL_DENY=mcp__figma__*` 判 allow)的根。
315
318
  *
316
- * ⚠️ 仍然拒的那一格,理由**换了**(别再照旧引「approval.ts 还不认」):`<ns>__<peer>` **两段形**
317
- * (`mcp__figma`)core 全家解成 covering,本函数判它「缺段」拒。挡它的不再是消费腿的能力,而是
318
- * **一名两义**:同一个 `mcp__figma` 既可能是运维想写的「整台服务器」,也可能是他把三段名写漏了一段
319
- * (`mcp__figma__get_file` 少打一截),两者在 boot 期无 roster 可判——所以三条腿一律响亮拒,并在
320
- * 指引里直接给出**无歧义**的等价写法 `mcp__figma__*`(见下方 guidance)。放宽两段形是一次
321
- * behavior-facing 的放宽(三条腿同时变宽,且方向是「更少拒绝」),要走独立批的三问,不是本函数的补丁。
319
+ * 🔴 **S-1047(core 7.41.0 #1156)收掉的分叉:两段覆盖形 `<ns>__<server>`(`mcp__figma`)自 7.116.0 起受理**。core 全家把它解成
320
+ * covering(这台服务器的每一只工具,与 `mcp__figma__*` 同义),core 的判词 `judgeRuleToolName` 判它 `current`,本仓会话规则写口也一直
321
+ * 收它;而本函数此前以「一名两义」(运维也可能是把三段名写漏了一段)拒它,句子却说它「永远不生效」—— 对它不成立,且同一个名字在本仓两张表上
322
+ * 两种答案。收成一个答案:受理。代价(三问在 CHANGELOG 7.116.0):少了「三段名漏打一截」的那一次拦截 —— 想要一只工具就写全名;
323
+ * 拒放集合的变化逐条在 MIGRATION 7.116.0 节。服务器段带通配(`mcp__*`)仍拒(core 的匹配器对服务器段逐字比,一只都够不着;7.116.0 起它是
324
+ * 「对端段铸不出」的一例,不再单列)。
322
325
  */
323
326
  export interface UnmatchableToolName {
324
327
  name: string;
@@ -354,7 +357,7 @@ export interface UnmatchableToolNameJudgment {
354
357
  * (`formatUnmatchableToolNames`)是两只导出,句子里那半句(「这些表都是整串逐字比对」)只对名表成立 —— 规则编译器的
355
358
  * 三只桶(与 `APPROVAL_GRANT`)是按规则文法编、按 covering 匹配的,不是整串比对。
356
359
  *
357
- * 句子对两种消费者、三种态度都成立:每一条要么永远匹配不到活工具(退役名 / 无协议前缀的短名 / 缺段的 MCP 名 / 名表里的内容形),要么编不成
360
+ * 句子对两种消费者、三种态度都成立:每一条要么永远匹配不到活工具(退役名 / 无协议前缀的短名 / 缺段或对端段铸不出的协议名 / 名表里的内容形),要么编不成
358
361
  * 一条规则(core 文法的判词原样带出);名字按写的样子读,不做别名折叠、不补协议前缀。逐条的「为什么 / 怎么改」在 `→` 之后。
359
362
  */
360
363
  export declare function unmatchableToolNamesRefusal(source: string, names: readonly string[], judgment: UnmatchableToolNameJudgment): {
@@ -364,7 +367,7 @@ export declare function unmatchableToolNamesRefusal(source: string, names: reado
364
367
  /**
365
368
  * 7.115.0 —— 运维 allow 规则 `APPROVAL_GRANT` 的**拒启判据与拒因句**(同一只函数;`config-invariants.ts` 的 `…Refusal` 形:
366
369
  * 返回句子 = 拒,`undefined` = 通过)。判据与请求腿的 `settings.permissions.allow` 是**同一只**:
367
- * ① 名字形与 core 文法 —— {@link unmatchableToolNamesRefusal},消费者 `rule-policy`、behavior `allow`(退役名 / 无前缀短名 / 缺段 MCP 名 /
370
+ * ① 名字形与 core 文法 —— {@link unmatchableToolNamesRefusal},消费者 `rule-policy`、behavior `allow`(退役名 / 无前缀短名 / 缺段或对端段铸不出的协议名 /
368
371
  * 反斜杠 / 超长 / 内容形与参数级的 allow;句子点名哪一条、为什么、怎么改);
369
372
  * ② 整表试编一次 —— 编的就是治理折叠里那一只(`runtime-governance.ts` {@link compileApprovalGrantLayers}),于是 core 的整表帽这类
370
373
  * 只在编译时才抛的判据也在启动时响亮,而不是每个任务一次 500。
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { DEFAULT_MODEL_REF } from "./model-select.js";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, isModelPriced, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules, WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, parseRuleText } from "@sema-agent/core";
6
+ import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, isModelPriced, PROTOCOL_TABLE, RECOMMENDED_SENSITIVE_PATTERNS, judgeRuleToolName, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules, WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable } from "@sema-agent/core";
7
7
  import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES, CENTER_CREDENTIAL_ENV } from "@sema-agent/settings-schema";
8
8
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js";
9
9
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
@@ -18,6 +18,7 @@ import { buildReadFacePostureNotice, readFaceForPosture } from "./read-face-post
18
18
  import { THINKING_LEVELS, THINKING_FORMATS, buildModelCompat, isThinkingFormat, isThinkingTier } from "./model-compat.js";
19
19
  import {} from "./posture-source.js";
20
20
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
21
+ import { describeUnreachableNamespacedRuleName, ruleEntryToolName, unreachableNamespacedRuleNameOf } from "./namespaced-rule-name.js";
21
22
  import { bakeOperatorPrincipalsRefusal, bakeRunnerCredentialRefusal, halfOpenDirectDoorRefusal, leaderDurableStoreRefusal, operatorPrincipalsRequiredRefusal } from "./config-invariants.js";
22
23
  import { REMOTE_EXEC_PROVIDER_WORDS, executionLaneOf, executionLaneRequiredRefusal, isSingleMachineDefaultLane, laneRequiredEnv } from "./execution-lane-caps.js";
23
24
  import { MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS } from "@sema-agent/settings-schema/execution-lanes";
@@ -1245,29 +1246,28 @@ function findUnmatchableToolNames(names, opts) {
1245
1246
  continue;
1246
1247
  }
1247
1248
  }
1248
- const retired = RETIRED_TOOL_NAMES.get(name);
1249
- if (retired !== undefined) {
1250
- out.push({ name, guidance: retired });
1249
+ const subject = opts.consumer === "tool-name-table" ? name : ruleEntryToolName(name);
1250
+ const verdict = judgeRuleToolName(subject, NO_DECLARED_TOOL_NAMES);
1251
+ if (verdict.kind === "retired") {
1252
+ out.push({ name, guidance: verdict.guidance });
1251
1253
  continue;
1252
1254
  }
1253
1255
  const uncompilable = uncompilableRuleGuidance(name, opts.behavior, opts.pathBases);
1254
- const subject = opts.consumer === "tool-name-table" ? name : (() => { const parsed = parseRuleText(name, opts.behavior); return "rule" in parsed ? parsed.rule.tool : name; })();
1255
1256
  if (uncompilable !== undefined) {
1256
1257
  out.push({ name, guidance: uncompilable });
1257
1258
  continue;
1258
1259
  }
1259
- const ns = protocolOf(subject);
1260
- if (ns === undefined) {
1261
- if (subject.includes("__"))
1262
- out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${presentToolName(name)}`).join(" or ")}` });
1260
+ if (verdict.kind === "unprefixed_namespace") {
1261
+ out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${presentToolName(name)}`).join(" or ")}` });
1263
1262
  continue;
1264
1263
  }
1265
- if (ns.parse(subject) === undefined) {
1266
- out.push({ name, guidance: `incomplete ${ns.id} name — the live form is ${ns.prefix}<server>__<tool>, or ${ns.prefix}<server>__* to cover every tool on that server` });
1267
- }
1264
+ const unreachable = unreachableNamespacedRuleNameOf(subject);
1265
+ if (unreachable !== undefined)
1266
+ out.push({ name, guidance: describeUnreachableNamespacedRuleName(unreachable) });
1268
1267
  }
1269
1268
  return out;
1270
1269
  }
1270
+ const NO_DECLARED_TOOL_NAMES = new Set();
1271
1271
  export function unmatchableToolNamesRefusal(source, names, judgment) {
1272
1272
  const bad = findUnmatchableToolNames(names, judgment);
1273
1273
  if (bad.length === 0)
@@ -52,6 +52,12 @@ export declare const MAX_OUTPUT_SCHEMA_CHARS = 32768;
52
52
  * 🔴 **server 侧禁裁列表 / 禁截描述**:渲染归引擎(单一属主)。本函数只判「这一条技能的形能不能用」。
53
53
  */
54
54
  export declare function validateUserSkills(skills: unknown): string | null;
55
+ /**
56
+ * core 7.41.0 #1303 —— 顶层技能带 `manifest` 的拒因句(单铸点)。出路只点本服务上**真存在**的旋钮:技能的两只清单
57
+ * (`allowedTools` / `disallowedTools`)、圈工具 = deny 规则(请求 `settings.permissions.deny` / 运维 `APPROVAL_DENY`)或子代定义的
58
+ * `agents[].allowTools`、圈写路径 = 会话规则 `allowDirs`(`PUT /v1/sessions/:id/policy`)。
59
+ */
60
+ export declare const SKILL_MANIFEST_RETIRED = "skill.manifest was retired in core 7.41.0 \u2014 a skill's tools are its allowedTools / disallowedTools now (an allow-rule and a deny-rule source for the run the skill is activated in, never a fence); move manifest.allowTools to allowedTools. To fence tools use deny rules (settings.permissions.deny) or a delegated agent's allowTools; to fence write paths use the session rules' allowDirs";
55
61
  export declare function createAdmission(ctx: {
56
62
  deps: FlatServiceDeps;
57
63
  runnerFor: FlatServiceDeps["runnerFor"];
@@ -5,13 +5,13 @@ import { HttpError } from "../security.js";
5
5
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, SETTINGS_PERMISSION_MODE_WORDS, isSettingsPermissionMode, parseTaskSettings, settingsPermissionBucketIssue, skillListingBudgetFractionOk, skillListingMaxDescOk, taskHooksHonored, taskSettingsKeyIssue } from "../task-settings.js";
6
6
  import { taskBodyKeyIssue } from "../request-key-closure.js";
7
7
  import { FILE_HISTORY_DISABLED_NO_RESTORE, fileHistoryRestoreConflict, inheritEnvFieldIssue, singleMemberOffFieldIssue } from "../request-value-closure.js";
8
- import { selfOrchestrationDenial } from "../task-workflow.js";
9
- import { centerEntitlementSourceWired } from "../runtime-caps-resolver.js";
8
+ import * as facts from "../capability-facts.js";
10
9
  import { buildDeadToolMatcherRefusal, deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
11
10
  import { judgeRequestWebSearch } from "../plugins/web-search.js";
12
- import { normalizeApproachNotice, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
11
+ import { describeUnreachableSkillToolListEntry, normalizeApproachNotice, skillToolListOk, unknownTaskAgentModelRef, unreachableSkillToolListEntryOf, validateTaskAgents } from "../spec-fields.js";
13
12
  import { isValidCwd, MAX_ADDITIONAL_DIRS, MAX_CWD_CHARS } from "../task-cwd.js";
14
13
  import { verifyRoundsFromBody } from "./verify-rounds.js";
14
+ import { skillCarriesRetiredManifest } from "./retired-skill-manifest.js";
15
15
  import { resolveDevicePlacementIntent } from "./device-placement.js";
16
16
  import { sendError, httpErrorCode } from "./send.js";
17
17
  export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
@@ -33,9 +33,19 @@ export function validateUserSkills(skills) {
33
33
  return `skill.content must be a non-empty string of at most ${MAX_SKILL_CONTENT_CHARS} characters (the engine rejects skills over this size outright at load time)`;
34
34
  if (s["baseDir"] !== undefined && !isValidCwd(s["baseDir"]))
35
35
  return `skill.baseDir must be an absolute path string of at most ${MAX_CWD_CHARS} characters with no ".." segment and no NUL byte (it is not resolved or read here — the engine only substitutes it into the skill's own text)`;
36
+ if (skillCarriesRetiredManifest(s))
37
+ return SKILL_MANIFEST_RETIRED;
38
+ for (const key of ["allowedTools", "disallowedTools"]) {
39
+ if (s[key] !== undefined && !skillToolListOk(s[key]))
40
+ return `skill.${key} must be an array of strings (permission-rule spellings: a tool name, mcp__<server> / mcp__<server>__*, Bash(<prefix>:*), Read(//<dir>/**))`;
41
+ }
42
+ const unreachable = unreachableSkillToolListEntryOf(s);
43
+ if (unreachable !== undefined)
44
+ return describeUnreachableSkillToolListEntry(unreachable);
36
45
  }
37
46
  return null;
38
47
  }
48
+ export const SKILL_MANIFEST_RETIRED = "skill.manifest was retired in core 7.41.0 — a skill's tools are its allowedTools / disallowedTools now (an allow-rule and a deny-rule source for the run the skill is activated in, never a fence); move manifest.allowTools to allowedTools. To fence tools use deny rules (settings.permissions.deny) or a delegated agent's allowTools; to fence write paths use the session rules' allowDirs";
39
49
  const MAX_IMAGES_PER_REQUEST = 20;
40
50
  const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
41
51
  function mentionableNames(config, catalog) {
@@ -361,10 +371,10 @@ export function createAdmission(ctx) {
361
371
  return null;
362
372
  }
363
373
  {
364
- const denial = selfOrchestrationDenial({ selfOrchestrationEnabled: deps.config.selfOrchestrationEnabled, requirePrincipal: deps.config.requirePrincipal }, centerEntitlementSourceWired(deps.config));
374
+ const admission = facts.selfOrchestrationAdmission(deps);
365
375
  const wantsSelfOrchestration = body.selfOrchestration === true
366
376
  || parseTaskSettings(body.settings).settings?.ultracode === true;
367
- if (denial !== null && wantsSelfOrchestration) {
377
+ if (!admission.available && wantsSelfOrchestration) {
368
378
  sendError(res, 501, "capability.self_orchestration_required", "self-orchestration is enabled on this worker (SELF_ORCHESTRATION_ENABLED) but it is multi-tenant (REQUIRE_PRINCIPAL) and wires NO per-principal entitlement source, so the engine's allowWorkflows gate — which is tighten-only, meaning an absent resolver leaves it UNGATED — is fail-closed for every principal: no request can ever get the workflow engine here (GET /v1/capabilities reports workflows:false with workflowsGate.denial \"entitlement_resolver_absent\"). Wire a config center (SEMA_REGISTRY_URL + token, not dry-run) to gate it per principal, or set SELF_ORCHESTRATION_ENABLED=false — the honest posture for a worker that cannot grant it. To submit this task without the workflow engine, drop selfOrchestration / settings.ultracode; ultracode's other half (the max thinking tier) is available as reasoningEffort: \"max\"");
369
379
  return null;
370
380
  }
@@ -478,6 +488,13 @@ export function createAdmission(ctx) {
478
488
  sendError(res, 400, "request.field_conflict", "disableSkills: true means this run installs no skill at all (not the deployment's, not the request's), so it cannot come with a non-empty skills or agents[].skills — send one or the other");
479
489
  return null;
480
490
  }
491
+ {
492
+ const v = body.userInvokedSkills;
493
+ if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
494
+ sendError(res, 400, "request.field_invalid", "userInvokedSkills must be an array of non-empty skill-name strings (each one a skill this run carries)");
495
+ return null;
496
+ }
497
+ }
481
498
  for (const key of ["excludeTools", "deferTools"]) {
482
499
  const v = body[key];
483
500
  if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
@@ -92,6 +92,9 @@ export declare function runOwnerReadable(principal: string | undefined, owner: s
92
92
  * and two more hand-inlined copies sat in approvals-assistant — a future hardening of this judgment would have
93
93
  * missed them. All call sites now consume THIS function; do not re-introduce a sibling. */
94
94
  export declare function explicitOperatorOk(principal: string | undefined, operatorPrincipals: readonly string[]): boolean;
95
+ /** operator 面族在「名单为空」这一形上的**唯一**拒发口:事实 `operatorLane`(`capability-facts.ts`)为假 ⇒ 这一面对任何
96
+ * 身份都打不通 = 这台部署上不可用 ⇒ 501,不是鉴权拒(403 只留给「名单非空而你不在名单上」)。能力位读的是同一只事实。 */
97
+ export declare function sendOperatorPrincipalsRequired(res: ServerResponse): void;
95
98
  /** May `principal` act as an OPERATOR on the F4 approval queue? Empty `operatorPrincipals` = legacy
96
99
  * behavior (the shared service token IS the operator boundary → everyone authenticated is an operator).
97
100
  * Otherwise only listed principals are operators. */
@@ -95,6 +95,9 @@ export function runOwnerReadable(principal, owner, config) {
95
95
  export function explicitOperatorOk(principal, operatorPrincipals) {
96
96
  return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
97
97
  }
98
+ export function sendOperatorPrincipalsRequired(res) {
99
+ sendError(res, 501, "capability.operator_principals_required", "this endpoint is operator-only and OPERATOR_PRINCIPALS is empty, so no caller can be an operator on this deployment; configure OPERATOR_PRINCIPALS to enable it");
100
+ }
98
101
  export function isOperator(principal, operatorPrincipals) {
99
102
  if (operatorPrincipals.length === 0)
100
103
  return true;
@@ -66,6 +66,7 @@ const WORKER_SWAP_REDEEMABLE = {
66
66
  read_face_damaged: false,
67
67
  read_face_forged: false,
68
68
  write_face_damaged: false,
69
+ skill_grants_damaged: false,
69
70
  constraint_chain_missing: false,
70
71
  };
71
72
  function checkpointRowRedeemableElsewhere(reason) {
@@ -121,13 +122,21 @@ export function checkpointErrorDisposition(e) {
121
122
  const CHECKPOINT_ERROR_FIELD_ROLE = {
122
123
  boundCallId: "binding",
123
124
  boundInputHash: "binding",
125
+ boundGrantDigest: "regrant",
124
126
  answer: "answer",
125
127
  hostDecision: "outcome_cell",
126
128
  approver: "outcome_cell",
127
129
  taskConfig: "config",
128
130
  inheritedGate: "config",
129
131
  };
130
- const NON_BINDING_INVALID_OUTCOME_FIELDS = new Set(Object.keys(CHECKPOINT_ERROR_FIELD_ROLE).filter((f) => CHECKPOINT_ERROR_FIELD_ROLE[f] === "outcome_cell"));
132
+ const ROLE_MINTS_RESUME_OUTCOME_INVALID = {
133
+ binding: false,
134
+ answer: false,
135
+ outcome_cell: true,
136
+ regrant: true,
137
+ config: false,
138
+ };
139
+ const NON_BINDING_INVALID_OUTCOME_FIELDS = new Set(Object.keys(CHECKPOINT_ERROR_FIELD_ROLE).filter((f) => ROLE_MINTS_RESUME_OUTCOME_INVALID[CHECKPOINT_ERROR_FIELD_ROLE[f]]));
131
140
  function notResumableAnswer() {
132
141
  return { status: 409, body: { error: "run is not in a resumable (suspended) state (already resumed, decided, or expired)", errorCode: "conflict.not_resumable" } };
133
142
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 技能上已退役的 `manifest` 键 —— 「一条技能还带它」的**唯一判据**(core 7.41.0 起 `SkillSpec.manifest` 退役,零别名)。
3
+ *
4
+ * 两个读者,同一句话:
5
+ * · 提交口的顶层技能门(`admission.ts` 的 `validateUserSkills`):带它(任何值)⇒ 点名拒;
6
+ * · 随包排空脚本(`scripts/drain-check-local.mjs` 的 `retired-skill-manifest` 判据,经脚本隔壁的编译产物 `dist/http/retired-skill-manifest.js` 取):
7
+ * 升级前停驻、持久化的提交体里仍带它的行,升级后续跑会被引擎按名拒(顶层 `skills[]`)或丢掉那只子代定义(`agents[].skills[]`),运维升级前要能数出来。
8
+ *
9
+ * 「带」= 键在场、值是什么都算(`null` / 空对象同样是「我带了 manifest」)—— 与引擎的退役键表读法同形(`RETIRED_TASK_SPEC_KEYS["skills[].manifest"]`:`at !== undefined`)。
10
+ * 入参是持久化的 JSON(可能来自任何旧版本),逐层判形,不信型面。
11
+ *
12
+ * 🔴 本模块**零运行期 import**(随包脚本要在没有服务其余部分的位置加载它,与 `checkpoint-ctx.js` 同一姿势)—— 往里加 import 要先想想脚本那一头。
13
+ */
14
+ /** 一条技能(提交体 `skills[]` / `agents[].skills[]` 的元素)是否还带退役的 `manifest` 键。 */
15
+ export declare function skillCarriesRetiredManifest(skill: unknown): boolean;
16
+ /** 持久化体里哪一层带着它:顶层 `skills[]`,或某只子代定义的 `agents[].skills[]`。 */
17
+ export type RetiredSkillManifestLayer = "skills" | "agents.skills";
18
+ /**
19
+ * 一份提交体(持久化的 JSON)里有没有技能还带 `manifest`,有则说是哪一层(两层都有 ⇒ 取顶层,先说引擎会响亮拒的那一层);都没有 ⇒ `undefined`。
20
+ * 顶层不是数组 / 子代不是对象 / 子代的 `skills` 不是数组 ⇒ 该处不算(受理门从不放进这种形;盘上若有也不是「带 manifest」)。
21
+ */
22
+ export declare function retiredSkillManifestLayerOf(body: unknown): RetiredSkillManifestLayer | undefined;
23
+ //# sourceMappingURL=retired-skill-manifest.d.ts.map
@@ -0,0 +1,22 @@
1
+ export function skillCarriesRetiredManifest(skill) {
2
+ return skill !== null && typeof skill === "object" && !Array.isArray(skill) && Object.hasOwn(skill, "manifest");
3
+ }
4
+ const isArray = (v) => Array.isArray(v);
5
+ export function retiredSkillManifestLayerOf(body) {
6
+ if (body === null || typeof body !== "object" || Array.isArray(body))
7
+ return undefined;
8
+ const { skills, agents } = body;
9
+ if (isArray(skills) && skills.some(skillCarriesRetiredManifest))
10
+ return "skills";
11
+ if (isArray(agents)) {
12
+ for (const agent of agents) {
13
+ if (agent === null || typeof agent !== "object" || Array.isArray(agent))
14
+ continue;
15
+ const own = agent.skills;
16
+ if (isArray(own) && own.some(skillCarriesRetiredManifest))
17
+ return "agents.skills";
18
+ }
19
+ }
20
+ return undefined;
21
+ }
22
+ //# sourceMappingURL=retired-skill-manifest.js.map
@@ -84,8 +84,9 @@ export interface RouteRow {
84
84
  * `test/route-shape-roster.test.ts` 层E 的能力位词干也取自这里 —— 域的身份是声明出来的,
85
85
  * 不是从函数名反推的。函数真名与它的一致性由 `route-order-freeze` 自检格钉住。 */
86
86
  readonly label: string;
87
- /** **挂载条件**(只有真有条件的行才写):缺席 = 恒挂载。域头的合取式就是挂载条件 —— 阀门没开 ⇒
88
- * 请求落到分派器尾的全局 404(**诚实缺席,不是 501**;判据全文在各域模块头注)。 */
87
+ /** **挂载条件**(只有真有条件的行才写):缺席 = 恒挂载。今天只剩 diagnostics 一行(它没有能力位,缺席形只出现在测试夹具)。
88
+ * 「这台部署上不可用」的面**不**用它:那些面恒挂载、域头按能力事实(`src/capability-facts.ts`)答 501,与能力位同一只函数
89
+ * —— 未挂载落全局 404 会让「这台没有这一面」与「URL 写错了」同码,也违背契约「false ⇒ 501」。 */
89
90
  readonly enabled?: (deps: FlatServiceDeps) => boolean;
90
91
  }
91
92
  export declare const ROUTES: readonly RouteRow[];
@@ -52,9 +52,9 @@ export const ROUTES = [
52
52
  row({ handler: handleRunMemoryCapture, decls: RUN_MEMORY_CAPTURE_ROUTES, position: "post-credential", label: "run-memory-capture" }),
53
53
  row({ handler: handleMcpReconnect, decls: MCP_RECONNECT_ROUTES, position: "post-credential", label: "mcp-reconnect" }),
54
54
  row({ handler: handleBackground, decls: BACKGROUND_ROUTES, position: "post-credential", label: "background" }),
55
- row({ handler: handleLeader, decls: LEADER_ROUTES, position: "post-credential", label: "leader", enabled: (deps) => Boolean(deps.leaderEndpoint) }),
55
+ row({ handler: handleLeader, decls: LEADER_ROUTES, position: "post-credential", label: "leader" }),
56
56
  row({ handler: handleImages, decls: IMAGES_ROUTES, position: "post-credential", label: "images" }),
57
- row({ handler: handleApprovalsAssistant, decls: APPROVALS_ASSISTANT_ROUTES, position: "post-credential", label: "approvals-assistant", enabled: (deps) => Boolean(deps.checkpointStore) }),
57
+ row({ handler: handleApprovalsAssistant, decls: APPROVALS_ASSISTANT_ROUTES, position: "post-credential", label: "approvals-assistant" }),
58
58
  row({ handler: handleObservability, decls: OBSERVABILITY_ROUTES, position: "post-credential", label: "observability" }),
59
59
  row({ handler: handleDiagnostics, decls: DIAGNOSTICS_ROUTES, position: "post-credential", label: "diagnostics", enabled: (deps) => Boolean(deps.staticWiring) }),
60
60
  row({ handler: handleAdoption, decls: ADOPTION_ROUTES, position: "post-credential", label: "adoption" }),
@@ -62,15 +62,15 @@ export const ROUTES = [
62
62
  row({ handler: handleAdminDrain, decls: ADMIN_DRAIN_ROUTES, position: "post-credential", label: "admin-drain" }),
63
63
  row({ handler: handleDevices, decls: DEVICES_ROUTES, position: "post-credential", label: "devices" }),
64
64
  row({ handler: handleAdminConfigRefresh, decls: ADMIN_CONFIG_REFRESH_ROUTES, position: "post-credential", label: "admin-config-refresh" }),
65
- row({ handler: handleConfigCatalog, decls: CONFIG_CATALOG_ROUTES, position: "post-credential", label: "config-catalog", enabled: (deps) => deps.config.configCatalogEnabled === true }),
65
+ row({ handler: handleConfigCatalog, decls: CONFIG_CATALOG_ROUTES, position: "post-credential", label: "config-catalog" }),
66
66
  row({ handler: handleMemoryOptOut, decls: MEMORY_OPTOUT_ROUTES, position: "post-credential", label: "memory-opt-out" }),
67
67
  row({ handler: handleMemoryPolicy, decls: MEMORY_POLICY_ROUTES, position: "post-credential", label: "memory-policy" }),
68
68
  row({ handler: handleMemoryBundle, decls: MEMORY_BUNDLE_ROUTES, position: "post-credential", label: "memory-bundle" }),
69
69
  row({ handler: handleMemoryCompliance, decls: MEMORY_COMPLIANCE_ROUTES, position: "post-credential", label: "memory-compliance" }),
70
70
  row({ handler: handleMemoryOrigin, decls: MEMORY_ORIGIN_ROUTES, position: "post-credential", label: "memory-origin" }),
71
- row({ handler: handleMemoryConsolidation, decls: MEMORY_CONSOLIDATION_ROUTES, position: "post-credential", label: "memory-consolidation", enabled: (deps) => Boolean(deps.memoryConsolidation) }),
71
+ row({ handler: handleMemoryConsolidation, decls: MEMORY_CONSOLIDATION_ROUTES, position: "post-credential", label: "memory-consolidation" }),
72
72
  row({ handler: handleRules, decls: RULES_ROUTES, position: "post-credential", label: "rules" }),
73
- row({ handler: handleSharedMemory, decls: SHARED_MEMORY_ROUTES, position: "post-credential", label: "shared-memory", enabled: (deps) => Boolean(deps.sharedMemoryStore) && Boolean(deps.orgMemoryDirectory) }),
73
+ row({ handler: handleSharedMemory, decls: SHARED_MEMORY_ROUTES, position: "post-credential", label: "shared-memory" }),
74
74
  row({ handler: handleSessionsList, decls: SESSIONS_LIST_ROUTES, position: "post-credential", label: "sessions-list" }),
75
75
  row({ handler: handleAgentRoster, decls: AGENT_ROSTER_ROUTES, position: "post-credential", label: "agent-roster" }),
76
76
  row({ handler: handleSessions, decls: SESSIONS_ROUTES, position: "post-credential", label: "sessions" }),
@@ -8,6 +8,7 @@ import { sendJson, sendError } from "../send.js";
8
8
  import { readinessOf, billableRefusalOf, sendBillableRefusal } from "../readiness.js";
9
9
  import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
10
10
  import { assertNever } from "../route-ctx.js";
11
+ import * as facts from "../../capability-facts.js";
11
12
  import { createDurableRun } from "./runs.js";
12
13
  export const A2A_PROTOCOL_VERSION = "1.0";
13
14
  export const A2A_VERSION_HEADER = "a2a-version";
@@ -25,13 +26,13 @@ export async function handleA2aServe(req, res, match, ctx) {
25
26
  const { deps } = ctx;
26
27
  switch (match.decl.id) {
27
28
  case "a2a-agent-card": {
28
- const serve = deps.config.a2aServe;
29
- if (!serve) {
29
+ const peer = facts.a2aServe(deps);
30
+ if (!peer.available) {
30
31
  sendError(res, 404, "not_found.route", "not found");
31
32
  return;
32
33
  }
33
34
  res.setHeader(A2A_VERSION_HEADER, A2A_PROTOCOL_VERSION);
34
- sendJson(res, 200, buildAgentCard(serve));
35
+ sendJson(res, 200, buildAgentCard(peer.serve));
35
36
  return;
36
37
  }
37
38
  default:
@@ -44,10 +45,8 @@ const JSONRPC_METHOD_NOT_FOUND = -32601;
44
45
  const JSONRPC_INVALID_PARAMS = -32602;
45
46
  const JSONRPC_INTERNAL_ERROR = -32603;
46
47
  const A2A_TASK_NOT_FOUND = -32001;
47
- const A2A_UNSUPPORTED_OPERATION = -32004;
48
48
  const A2A_CONTENT_TYPE_NOT_SUPPORTED = -32005;
49
49
  export const A2A_TASK_NOT_FOUND_MESSAGE = "task not found";
50
- const A2A_NO_RUN_STORE_MESSAGE = "this deployment has no durable run store, so A2A tasks cannot be created or polled (set DB_BACKEND=mysql|pg|local)";
51
50
  const A2A_STATE_BY_RUN_STATUS = {
52
51
  running: "working",
53
52
  completed: "completed",
@@ -188,11 +187,8 @@ function readBlocking(params) {
188
187
  function isStaleRun(run, runStaleSec) {
189
188
  return run.status === "running" && Date.now() - new Date(run.updatedAt).getTime() > runStaleSec * 1000;
190
189
  }
191
- async function waitForRun(ctx, res, taskId, budgetMs) {
190
+ async function waitForRun(ctx, store, res, taskId, budgetMs) {
192
191
  const { deps } = ctx;
193
- const store = deps.runStore;
194
- if (!store)
195
- return null;
196
192
  const deadline = Date.now() + budgetMs;
197
193
  let last = null;
198
194
  for (;;) {
@@ -219,7 +215,7 @@ async function waitForRun(ctx, res, taskId, budgetMs) {
219
215
  });
220
216
  }
221
217
  }
222
- async function handleMessageSend({ req, res, ctx, params, serve }) {
218
+ async function handleMessageSend({ req, res, ctx, params, serve, runStore }) {
223
219
  const { deps } = ctx;
224
220
  const { rateLimited, quotaExceeded } = ctx.helpers;
225
221
  const message = asRecord(params.message, "params.message");
@@ -232,8 +228,6 @@ async function handleMessageSend({ req, res, ctx, params, serve }) {
232
228
  const skillId = skill?.id;
233
229
  const scenario = skill?.scenario;
234
230
  const blocking = readBlocking(params);
235
- if (!deps.runStore)
236
- throw new RpcError(A2A_UNSUPPORTED_OPERATION, A2A_NO_RUN_STORE_MESSAGE);
237
231
  const idemScope = `${ctx.req.source ?? ""}\x1f${gatedPrincipal(req, deps.config) ?? ""}`;
238
232
  const fingerprint = createHash("sha256")
239
233
  .update(messageId).update("\x1f")
@@ -248,7 +242,7 @@ async function handleMessageSend({ req, res, ctx, params, serve }) {
248
242
  ...(contextId !== undefined ? { sessionId: contextId } : {}),
249
243
  ...(scenario !== undefined ? { scenario } : {}),
250
244
  };
251
- const replay = await deps.runStore.getRun(body.taskId).catch(() => undefined);
245
+ const replay = await runStore.getRun(body.taskId).catch(() => undefined);
252
246
  if (replay && (replay.owner === null || replay.owner === gatedPrincipal(req, deps.config))) {
253
247
  return taskFromRun(replay, isStaleRun(replay, deps.config.runStaleSec));
254
248
  }
@@ -280,7 +274,7 @@ async function handleMessageSend({ req, res, ctx, params, serve }) {
280
274
  }
281
275
  const taskId = receiptBody.taskId;
282
276
  const sessionId = receiptBody.sessionId;
283
- const run = await waitForRun(ctx, res, taskId, blocking ? serve.blockingWaitMs : 0);
277
+ const run = await waitForRun(ctx, runStore, res, taskId, blocking ? serve.blockingWaitMs : 0);
284
278
  if (run)
285
279
  return taskFromRun(run, isStaleRun(run, deps.config.runStaleSec));
286
280
  return {
@@ -298,12 +292,10 @@ function durableIdempotencyTaskId(scope, messageId) {
298
292
  const hex = b.toString("hex");
299
293
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
300
294
  }
301
- async function handleTasksGet({ req, ctx, params }) {
295
+ async function handleTasksGet({ req, ctx, params, runStore }) {
302
296
  const { deps } = ctx;
303
297
  const id = asNonEmptyString(params.id, "params.id", MAX_TASK_ID_CHARS);
304
- if (!deps.runStore)
305
- throw new RpcError(A2A_UNSUPPORTED_OPERATION, A2A_NO_RUN_STORE_MESSAGE);
306
- const run = await deps.runStore.getRun(id);
298
+ const run = await runStore.getRun(id);
307
299
  if (!run)
308
300
  throw new RpcError(A2A_TASK_NOT_FOUND, A2A_TASK_NOT_FOUND_MESSAGE);
309
301
  if (run.owner !== null && gatedPrincipal(req, deps.config) !== run.owner)
@@ -322,11 +314,12 @@ export async function handleA2a(req, res, match, ctx) {
322
314
  default:
323
315
  return assertNever(match.decl.id);
324
316
  }
325
- const serve = deps.config.a2aServe;
326
- if (!serve) {
317
+ const peer = facts.a2aServe(deps);
318
+ if (!peer.available) {
327
319
  sendError(res, 404, "not_found.route", "not found");
328
320
  return;
329
321
  }
322
+ const { serve, runStore } = peer;
330
323
  const requestedVersion = req.headers[A2A_VERSION_HEADER];
331
324
  res.setHeader(A2A_VERSION_HEADER, A2A_PROTOCOL_VERSION);
332
325
  if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
@@ -372,7 +365,7 @@ export async function handleA2a(req, res, match, ctx) {
372
365
  }
373
366
  deps.logger?.debug?.("a2a_serve_rpc", { method, ...(typeof requestedVersion === "string" ? { requestedVersion: requestedVersion.slice(0, 32) } : {}) });
374
367
  try {
375
- const result = await handler({ req, res, ctx, params: params, serve });
368
+ const result = await handler({ req, res, ctx, params: params, serve, runStore });
376
369
  if (result === undefined) {
377
370
  deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: "refused" });
378
371
  return;
@@ -1,8 +1,9 @@
1
1
  import { sendJson, sendError } from "../send.js";
2
- import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
2
+ import { gatedPrincipal, explicitOperatorOk, sendOperatorPrincipalsRequired, sendPrincipalRefusal } from "../principal-gate.js";
3
+ import * as facts from "../../capability-facts.js";
3
4
  import { assertNever } from "../route-ctx.js";
4
5
  export const ADMIN_CONFIG_REFRESH_PATH = "/v1/admin/config/refresh";
5
- const OPERATOR_ONLY_MESSAGE = "config refresh is operator-only: the caller principal is not in OPERATOR_PRINCIPALS (an empty list means NO one is an operator — configure it to enable this endpoint)";
6
+ const OPERATOR_ONLY_MESSAGE = "config refresh is operator-only: the caller principal is not in OPERATOR_PRINCIPALS";
6
7
  export async function handleAdminConfigRefresh(req, res, match, ctx) {
7
8
  const { deps } = ctx;
8
9
  switch (match.decl.id) {
@@ -12,6 +13,10 @@ export async function handleAdminConfigRefresh(req, res, match, ctx) {
12
13
  sendPrincipalRefusal(res, req, deps.config);
13
14
  return;
14
15
  }
16
+ if (!facts.operatorLane(deps).available) {
17
+ sendOperatorPrincipalsRequired(res);
18
+ return;
19
+ }
15
20
  if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
16
21
  sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
17
22
  return;
@@ -1,9 +1,10 @@
1
1
  import { sendJson, sendError } from "../send.js";
2
- import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
2
+ import { gatedPrincipal, explicitOperatorOk, sendOperatorPrincipalsRequired, sendPrincipalRefusal } from "../principal-gate.js";
3
+ import * as facts from "../../capability-facts.js";
3
4
  import { assertNever } from "../route-ctx.js";
4
5
  export const ADMIN_DRAIN_PATH = "/v1/admin/drain";
5
6
  export const MAX_DRAIN_REASON_CHARS = 256;
6
- const OPERATOR_ONLY_MESSAGE = "drain declaration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS (an empty list means NO one is an operator — configure it to enable this endpoint)";
7
+ const OPERATOR_ONLY_MESSAGE = "drain declaration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS";
7
8
  export async function handleAdminDrain(req, res, match, ctx) {
8
9
  const { deps } = ctx;
9
10
  switch (match.decl.id) {
@@ -13,6 +14,10 @@ export async function handleAdminDrain(req, res, match, ctx) {
13
14
  sendPrincipalRefusal(res, req, deps.config);
14
15
  return;
15
16
  }
17
+ if (!facts.operatorLane(deps).available) {
18
+ sendOperatorPrincipalsRequired(res);
19
+ return;
20
+ }
16
21
  if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
17
22
  sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
18
23
  return;
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { sendJson, sendError } from "../send.js";
3
- import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
3
+ import { gatedPrincipal, explicitOperatorOk, sendOperatorPrincipalsRequired, sendPrincipalRefusal } from "../principal-gate.js";
4
4
  import { assertNever } from "../route-ctx.js";
5
+ import * as facts from "../../capability-facts.js";
5
6
  import { memoryOptOutPrincipalShapeProblem } from "../../plugins/memory-optout-grant-store-sql.js";
6
7
  export const ADMIN_MEMORY_OPTOUT_PATH = "/v1/admin/memory-optout";
7
8
  const PutBodySchema = z.object({ allow: z.boolean(), note: z.string().min(1).max(500).optional() }).strict();
@@ -15,16 +16,29 @@ export async function handleMemoryOptOut(req, res, match, ctx) {
15
16
  sendPrincipalRefusal(res, req, deps.config);
16
17
  return;
17
18
  }
19
+ const fact = facts.memoryOptOutGrant(deps);
20
+ if (!fact.available && fact.why === "operator_principals_empty") {
21
+ sendOperatorPrincipalsRequired(res);
22
+ return;
23
+ }
18
24
  if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
19
- sendError(res, 403, "auth.operator_only", "memory opt-out grant administration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS (an empty list means NO one is an operator — configure it to enable these endpoints)");
25
+ sendError(res, 403, "auth.operator_only", "memory opt-out grant administration is operator-only: the caller principal is not in OPERATOR_PRINCIPALS");
20
26
  return;
21
27
  }
22
28
  const operator = principal;
23
- const store = deps.memoryOptOutGrant;
24
- if (store === undefined) {
25
- sendError(res, 501, "capability.memory_optout_grant_required", "this deployment has no memory opt-out grant table: the per-principal memory-capture opt-out authorization store lives on a SQL store backend (DB_BACKEND=mysql|pg) and the local backend deliberately keeps no companion store for forgetting — wire a SQL backend to administer opt-out grants");
26
- return;
29
+ if (!fact.available) {
30
+ switch (fact.why) {
31
+ case "memory_optout_grant_absent":
32
+ sendError(res, 501, "capability.memory_optout_grant_required", "this deployment has no memory opt-out grant table: the per-principal memory-capture opt-out authorization store lives on a SQL store backend (DB_BACKEND=mysql|pg) and the local backend deliberately keeps no companion store for forgetting — wire a SQL backend to administer opt-out grants");
33
+ return;
34
+ case "operator_principals_empty":
35
+ sendOperatorPrincipalsRequired(res);
36
+ return;
37
+ default:
38
+ return assertNever(fact.why);
39
+ }
27
40
  }
41
+ const store = fact.store;
28
42
  if (target !== undefined && (target === null || memoryOptOutPrincipalShapeProblem(target) !== undefined)) {
29
43
  sendError(res, 400, "request.path_malformed", "invalid memory opt-out grant principal — the path segment must percent-decode to a non-empty principal of at most 190 chars with no leading/trailing whitespace or control characters (the deployment default row is addressed by PUT on the collection root, not by a path segment)");
30
44
  return;