@sema-agent/server 7.20.0 → 7.21.0-rc.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 (72) hide show
  1. package/USAGE.md +31 -3
  2. package/dist/adoption/runner.js +4 -5
  3. package/dist/approval-card.d.ts +52 -0
  4. package/dist/approval-card.js +108 -0
  5. package/dist/boot/governance-seams.d.ts +112 -0
  6. package/dist/boot/governance-seams.js +151 -0
  7. package/dist/boot/leader.d.ts +3 -0
  8. package/dist/boot/leader.js +7 -1
  9. package/dist/boot/resolve-spec.d.ts +3 -0
  10. package/dist/boot/resolve-spec.js +10 -2
  11. package/dist/boot/runner-deps.d.ts +6 -2
  12. package/dist/boot/runner-deps.js +13 -0
  13. package/dist/boot/stores.d.ts +1 -0
  14. package/dist/boot/stores.js +24 -5
  15. package/dist/config-types.d.ts +50 -4
  16. package/dist/config.js +129 -3
  17. package/dist/fleet/fleet-bus.d.ts +23 -2
  18. package/dist/fleet/fleet-bus.js +41 -2
  19. package/dist/fleet/subagent-tail-bus.d.ts +59 -0
  20. package/dist/fleet/subagent-tail-bus.js +60 -0
  21. package/dist/http/routes/capabilities.js +27 -0
  22. package/dist/http/routes/diagnostics.js +6 -0
  23. package/dist/http/routes/runs.js +10 -1
  24. package/dist/http/routes/tasks.js +15 -4
  25. package/dist/http/server.d.ts +5 -0
  26. package/dist/http/server.js +110 -7
  27. package/dist/leader/wire.d.ts +7 -1
  28. package/dist/leader/wire.js +12 -5
  29. package/dist/main.js +33 -8
  30. package/dist/memory-posture.d.ts +80 -0
  31. package/dist/memory-posture.js +40 -0
  32. package/dist/observability/fail-open.d.ts +4 -0
  33. package/dist/observability/fail-open.js +4 -0
  34. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -1
  35. package/dist/parked-decide.js +9 -0
  36. package/dist/plugins/approval-ask-store-sql.d.ts +3 -1
  37. package/dist/plugins/approval-ask-store-sql.js +5 -6
  38. package/dist/plugins/background-agent-store-sql.js +4 -1
  39. package/dist/plugins/checkpoint-store-sql.d.ts +2 -0
  40. package/dist/plugins/checkpoint-store-sql.js +4 -5
  41. package/dist/plugins/file-run-store.d.ts +2 -1
  42. package/dist/plugins/image-bake-store-sql.js +5 -5
  43. package/dist/plugins/memory-embedder-fingerprint.d.ts +23 -0
  44. package/dist/plugins/memory-embedder-fingerprint.js +28 -1
  45. package/dist/plugins/memory-engine-tidb.js +3 -5
  46. package/dist/plugins/memory-engine-vector-util.d.ts +0 -5
  47. package/dist/plugins/memory-engine-vector-util.js +10 -4
  48. package/dist/plugins/memory-run-store.d.ts +2 -1
  49. package/dist/plugins/pg-session-storage.js +3 -3
  50. package/dist/plugins/run-store-sql.d.ts +12 -3
  51. package/dist/plugins/run-store-sql.js +4 -3
  52. package/dist/plugins/session-policy-store-sql.d.ts +1 -0
  53. package/dist/plugins/session-policy-store-sql.js +3 -3
  54. package/dist/plugins/shared-memory-store-sql.js +4 -10
  55. package/dist/plugins/sql-driver.d.ts +7 -5
  56. package/dist/plugins/sql-errors.d.ts +41 -0
  57. package/dist/plugins/sql-errors.js +25 -0
  58. package/dist/plugins/tidb-session-storage.js +3 -3
  59. package/dist/plugins/workflow-run-store-sql.js +4 -4
  60. package/dist/run-local.js +15 -0
  61. package/dist/runs.js +76 -38
  62. package/dist/task-mcp.d.ts +22 -1
  63. package/dist/task-mcp.js +71 -1
  64. package/dist/tool-approval.d.ts +35 -0
  65. package/dist/tool-approval.js +4 -1
  66. package/dist/trace/core-keyset-guard.d.ts +2 -2
  67. package/dist/trace/ledger-events.d.ts +43 -0
  68. package/dist/trace/ledger-events.js +2 -0
  69. package/dist/trace/ledger-sink.d.ts +3 -2
  70. package/dist/trace/project.d.ts +4 -1
  71. package/dist/trace/project.js +15 -0
  72. package/package.json +2 -2
@@ -26,9 +26,9 @@ import { toIso } from "./sql-row-helpers.js";
26
26
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
27
27
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, // 两孪生店同源(理由见 kernel 处顶注)
28
28
  } from "../session-sync-kernel.js";
29
- function isDupKey(err) {
30
- return Boolean(err) && err.code === "23505"; // PG unique_violation
31
- }
29
+ import { isPgUniqueViolation } from "./sql-errors.js";
30
+ /** 判据属主 = `sql-errors.ts`(A-032 P1-①)。 */
31
+ const isDupKey = isPgUniqueViolation;
32
32
  /** node-pg parses jsonb → object already; only strings need JSON.parse, everything else passes through. */
33
33
  function parseEntry(p) {
34
34
  return (typeof p === "string" ? JSON.parse(p) : p);
@@ -14,7 +14,14 @@
14
14
  * ── Dialect deltas, kept EXPLICIT (never hidden behind an abstraction) ────────────────────────────────
15
15
  * - `?` placeholders vs `$n` (transaction control itself is normalized by `SqlDriver`/`SqlTxConn` —
16
16
  * mysql2's native begin/commit/rollback vs PG's statement form; see sql-driver.ts)
17
- * - dup-key `ER_DUP_ENTRY` vs SQLSTATE `23505` (createRun's task_active claim race)
17
+ * - dup-key classification (createRun's task_active claim race) — the PREDICATE has a single owner
18
+ * (`sql-errors.ts`, A-032 P1-①); only the dialect DISPATCH is visible here
19
+ *
20
+ * ⚠️ The run ACTIVE-status word list written out in the `status IN (…)` / `NOT IN (…)` fragments below
21
+ * is the closed set owned by `store-contracts.ts` (`isTerminalRunStatus` / `isParkedRunStatus`). The SQL
22
+ * text stays literal ON PURPOSE (A12: both dialects readable side by side), so the tie is a PARITY GATE:
23
+ * `test/source-hygiene-gate.test.ts` → "A-032 P1-② run 活状态 SQL 字面 ↔ store-contracts 闭集属主 parity".
24
+ * core adding a TaskStatus member turns that gate red on every fragment here — update them in the same batch.
18
25
  * - null-safe owner compare: `<=>` (TiDB) vs `IS NOT DISTINCT FROM` (PG) — a null-owner run still matches
19
26
  * `owner=null` on both, single-DB-fleet defense-in-depth (see {@link requestCancel})
20
27
  * - multi-table DELETE: `DELETE t FROM t JOIN j …` (TiDB) vs `DELETE FROM t USING j …` (PG)
@@ -36,6 +43,7 @@ import type { Pool as PgPool } from "pg";
36
43
  import { type UsageRow } from "../usage-analytics.js";
37
44
  import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
38
45
  import { type SqlDriver } from "./sql-driver.js";
46
+ import type { LedgerEventType } from "../trace/ledger-events.js";
39
47
  export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
40
48
  /** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
41
49
  export declare class SqlRunStore {
@@ -43,7 +51,8 @@ export declare class SqlRunStore {
43
51
  constructor(db: SqlDriver);
44
52
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
45
53
  private q;
46
- /** mysql2 names the dup-key `ER_DUP_ENTRY`; PG raises SQLSTATE `23505`. */
54
+ /** Dup-key classification single owner in `sql-errors.ts` (A-032 P1-①; the old local form read
55
+ * only mysql2's string `code`, so an error carrying just `errno` fell through the claim-race arm). */
47
56
  private isDupKey;
48
57
  /** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope
49
58
  * (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */
@@ -97,7 +106,7 @@ export declare class SqlRunStore {
97
106
  heartbeat(taskId: string, owner: string | null): Promise<void>;
98
107
  /** Append one event. A single INSERT — `updated_at` (liveness) is owned by `heartbeat`, so there
99
108
  * is no second statement to keep atomic here. */
100
- appendEvent(taskId: string, seq: number, type: string, data: unknown): Promise<void>;
109
+ appendEvent(taskId: string, seq: number, type: LedgerEventType, data: unknown): Promise<void>;
101
110
  /** Highest event seq for a task (0 if none). Lets a resumed leg CONTINUE the durable event log past the
102
111
  * suspend (core 1.70 `resumeStream`) — the suspend wrote events 1..K, the resume appends from K+1 without
103
112
  * colliding on the (task_id, seq) PK. */
@@ -3,6 +3,7 @@ import { escapeLike } from "./sql-escape.js";
3
3
  import { pgSanitizeText } from "./pg-safe-json.js";
4
4
  import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
5
5
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
+ import { isDupKeyError } from "./sql-errors.js";
6
7
  /** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
7
8
  export class SqlRunStore {
8
9
  db;
@@ -13,10 +14,10 @@ export class SqlRunStore {
13
14
  q(tidb, pg) {
14
15
  return this.db.dialect === "tidb" ? tidb : pg;
15
16
  }
16
- /** mysql2 names the dup-key `ER_DUP_ENTRY`; PG raises SQLSTATE `23505`. */
17
+ /** Dup-key classification single owner in `sql-errors.ts` (A-032 P1-①; the old local form read
18
+ * only mysql2's string `code`, so an error carrying just `errno` fell through the claim-race arm). */
17
19
  isDupKey(err) {
18
- const code = err?.code;
19
- return Boolean(err) && (this.db.dialect === "tidb" ? code === "ER_DUP_ENTRY" : code === "23505");
20
+ return isDupKeyError(this.db.dialect, err);
20
21
  }
21
22
  /** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope
22
23
  * (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */
@@ -14,6 +14,7 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
14
14
  constructor(db: SqlDriver);
15
15
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
16
16
  private q;
17
+ /** 判据属主 = `sql-errors.ts`(A-032 P1-①;本站点旧形已是并集形,收编后语义不变)。 */
17
18
  private isDupKey;
18
19
  /** Read the stored rules for (session, principal-owner); null when none (parity with InMemory). */
19
20
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
@@ -43,6 +43,7 @@ import { createHash } from "node:crypto";
43
43
  import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
44
44
  import { pgHasUnstorable } from "./pg-safe-json.js";
45
45
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
46
+ import { isDupKeyError } from "./sql-errors.js";
46
47
  /** `session_policy.policy_key` 的**唯一铸造点**(core 复合键的 sha256)。
47
48
  *
48
49
  * 🔴 导出而不是让收编腿再抄一份(clay 宪法「schema 单一属主禁复制」):design/183 form b 的身份重绑要
@@ -61,10 +62,9 @@ export class SqlSessionPolicyStore {
61
62
  q(tidb, pg) {
62
63
  return this.db.dialect === "tidb" ? tidb : pg;
63
64
  }
65
+ /** 判据属主 = `sql-errors.ts`(A-032 P1-①;本站点旧形已是并集形,收编后语义不变)。 */
64
66
  isDupKey(e) {
65
- return this.db.dialect === "tidb"
66
- ? e?.code === "ER_DUP_ENTRY" || e?.errno === 1062
67
- : e?.code === "23505";
67
+ return isDupKeyError(this.db.dialect, e);
68
68
  }
69
69
  /** Read the stored rules for (session, principal-owner); null when none (parity with InMemory). */
70
70
  async getRules(sessionId, principal) {
@@ -1,16 +1,10 @@
1
1
  import { SHARED_MEMORY_READ_CAP_BYTES, SharedMemoryStoreError, } from "@sema-agent/core";
2
2
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
3
- /** 唯一键冲突的方言判别(approval-ask/checkpoint 同一对常量)——mysql2 `errno`,node-pg 挂 SQLSTATE。
3
+ import { isDupKeyError } from "./sql-errors.js";
4
+ /** 唯一键冲突的方言判别 —— 判据属主 = `sql-errors.ts`(A-032 P1-①:本站点原是全仓唯一的硬化形,
5
+ * 收编时把它**升格成属主**,其余六处按它对齐;`Reflect.get` 走原型链的理由见属主头注)。
4
6
  * 只用来把并发登记撞 PK 翻成一句人话,不让驱动错误对象漏给调用方。 */
5
- const DUP_ENTRY = 1062; // MySQL/TiDB ER_DUP_ENTRY
6
- const PG_UNIQUE_VIOLATION = "23505";
7
- function isDupKey(dialect, err) {
8
- if (err === null || typeof err !== "object")
9
- return false;
10
- // Reflect.get (not a spread): driver error classes carry these on the PROTOTYPE chain in some
11
- // versions, and `{...err}` would silently miss them — a missed dup-key leaks the raw driver object.
12
- return dialect === "tidb" ? Number(Reflect.get(err, "errno")) === DUP_ENTRY : String(Reflect.get(err, "code")) === PG_UNIQUE_VIOLATION;
13
- }
7
+ const isDupKey = isDupKeyError;
14
8
  /** Per-org plane state (`connecting` / `unavailable` / `connected`). One row per org scope. */
15
9
  export const SHARED_MEMORY_SCOPE_TABLE = "shared_memory_scope";
16
10
  /** The model-facing store registry: one row per browsable library. */
@@ -16,9 +16,11 @@
16
16
  * `CAST(? AS JSON)` vs `$n::jsonb`, `INSERT IGNORE` vs `ON CONFLICT DO NOTHING`, `<=>` vs
17
17
  * `IS NOT DISTINCT FROM`, VARBINARY vs `COLLATE "C"` — every one of these stays written out at
18
18
  * the call site so a reader sees BOTH statements side by side (the real-DB suites are the oracle).
19
- * - error classification: `ER_DUP_ENTRY`/errno 1062 vs SQLSTATE `23505`, and WHICH unique key
20
- * collided (mysql2 `sqlMessage` text vs pg `err.constraint`) those live with the store that
21
- * needs them, because the discrimination rule is store-specific, not driver-generic.
19
+ * - WHICH unique key collided (mysql2 `sqlMessage` text vs pg `err.constraint`) that lives with
20
+ * the store that needs it, because the attribution rule is store-specific, not driver-generic.
21
+ * ⚠️ The dup-key predicate ITSELF is NOT store-specific and no longer lives at the call sites:
22
+ * `sql-errors.ts` owns it (A-032 P1-①; 7+ hand-rolled copies had drifted into three different
23
+ * recognition sets, so the same driver error got opposite verdicts in different stores).
22
24
  * - per-query timeouts: mysql2 takes `{sql, timeout}` while node-pg takes a `QueryConfig` with
23
25
  * `query_timeout`. The counter family (write-behind-counter.ts) needs those and keeps its own
24
26
  * exec; this seam stays on the plain (sql, params) form.
@@ -58,8 +60,8 @@
58
60
  * WHAT MUST STAY EXPLICIT (never parameterized away)
59
61
  * placeholders (?/$n) · upsert form (ON DUPLICATE vs ON CONFLICT) · JSON casts (CAST(? AS JSON) /
60
62
  * $n::jsonb) · null-safe compare (<=> vs IS NOT DISTINCT FROM) AND its placeholder-arity fallout ·
61
- * key-column byte semantics (VARBINARY vs COLLATE "C") · isolation/txn verbs · error classification
62
- * (ER_DUP_ENTRY/errno vs SQLSTATE, sqlMessage vs err.constraint) · PG-only protocol-byte handling
63
+ * key-column byte semantics (VARBINARY vs COLLATE "C") · isolation/txn verbs · dup-key ATTRIBUTION
64
+ * (sqlMessage vs err.constraint; the dup-key predicate itself = `sql-errors.ts`) · PG-only protocol-byte handling
63
65
  * (pgSafeJsonStringify / pgProtocolJsonStringify / pgSanitizeText / pgHasUnstorable) · DDL. Each arm's
64
66
  * BEHAVIOR (what pgSafeJsonStringify/pgProtocolJsonStringify actually do to the bytes) stays written out
65
67
  * in `pg-safe-json.ts`, untouched — `dialectJsonEncoder`/`dialectProtocolJsonEncoder` below (design/158
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SQL 驱动错误的**方言判别**——「这是不是唯一键冲突」的单一属主(A-032 P1-①,病族
3
+ * single-semantic-multi-site-drift)。`sql-escape.ts` / `sql-row-helpers.ts` / `store-contracts.ts`
4
+ * 的同级叶子:零 store 逻辑、零驱动依赖(不 import mysql2/pg),纯谓词。
5
+ *
6
+ * ## 为什么必须只有一份
7
+ * 收编前这条谓词在 src/ 手铸 ≥7 处,而且**识别集互相漂移**:一半站点只认 `code === "ER_DUP_ENTRY"`,
8
+ * 一半只认 `errno === 1062`,少数两者都认。同一个驱动错误因此在不同店里得到相反判决——漏判的代价
9
+ * 不是"少一句人话",而是 typed 契约错误(`checkpoint.already_exists` / `agent_record.already_exists` /
10
+ * `idempotency_conflict`)退化成**裸驱动错误对象**上抛,调用方按错误码分流的那条腿整条静默失效。
11
+ *
12
+ * ## 识别集 = 两个归因键的**并集**(刻意)
13
+ * mysql2 对同一条 `ER_DUP_ENTRY` 同时挂 `code`(errno 的字符串名)与 `errno`(数字),两者是**同一
14
+ * 件事的两种写法**,任一在场即可判定 —— 认并集不会造出假阳性,而认单键会漏掉任何只保留另一个键的
15
+ * 形(驱动版本差异、代理/包装层、序列化往返)。这正是收编前两个真漏判的成因,门钉在
16
+ * `test/sql-dup-key-single-source.test.ts`。
17
+ *
18
+ * ## 为什么是 `Reflect.get` 而不是属性访问/展开
19
+ * 驱动错误类在某些版本把这两个键挂在**原型链**上:`{...err}` 只拷自有可枚举键,会**静默**丢掉它们,
20
+ * 于是一次 dup 变成一个裸驱动对象漏给调用方。`Reflect.get` 与属性访问一样走原型链,写成 `Reflect.get`
21
+ * 是把"必须走原型链"这件事写在码面上(收编前唯一的硬化形 `shared-memory-store-sql.ts` 即此形)。
22
+ *
23
+ * ## 本模块**不**管的事(与 `sql-driver.ts` 头注的分工一致)
24
+ * 「撞的是**哪一个** UNIQUE 键」留在店内:mysql2 把键名写进 `sqlMessage` 文本、node-pg 写进
25
+ * `err.constraint`,而怎么按键名分诊(良性重发 vs 必须重试的竞态)是 store-specific 的判别规则,
26
+ * 不是驱动通用的。先例:`image-bake-store-sql.ts` 的 `dupKeyName`。
27
+ */
28
+ import type { SqlDialect } from "./sql-driver.js";
29
+ /** MySQL/TiDB `ER_DUP_ENTRY` 的数字 errno。 */
30
+ export declare const MYSQL_ER_DUP_ENTRY_ERRNO = 1062;
31
+ /** MySQL/TiDB `ER_DUP_ENTRY` 的字符串错误码(mysql2 的 `code`)。 */
32
+ export declare const MYSQL_ER_DUP_ENTRY_CODE = "ER_DUP_ENTRY";
33
+ /** PostgreSQL `unique_violation` 的 SQLSTATE。 */
34
+ export declare const PG_UNIQUE_VIOLATION_SQLSTATE = "23505";
35
+ /** MySQL/TiDB 的唯一键冲突:`code === "ER_DUP_ENTRY"` **或** `errno === 1062`(同一件事的两种写法)。 */
36
+ export declare function isMysqlDupKeyError(err: unknown): boolean;
37
+ /** PostgreSQL 的唯一键冲突:SQLSTATE `23505`(node-pg 挂在 `code` 上)。 */
38
+ export declare function isPgUniqueViolation(err: unknown): boolean;
39
+ /** 方言分派口——双方言店的调用形(`isDupKeyError(this.db.dialect, e)`)。 */
40
+ export declare function isDupKeyError(dialect: SqlDialect, err: unknown): boolean;
41
+ //# sourceMappingURL=sql-errors.d.ts.map
@@ -0,0 +1,25 @@
1
+ /** MySQL/TiDB `ER_DUP_ENTRY` 的数字 errno。 */
2
+ export const MYSQL_ER_DUP_ENTRY_ERRNO = 1062;
3
+ /** MySQL/TiDB `ER_DUP_ENTRY` 的字符串错误码(mysql2 的 `code`)。 */
4
+ export const MYSQL_ER_DUP_ENTRY_CODE = "ER_DUP_ENTRY";
5
+ /** PostgreSQL `unique_violation` 的 SQLSTATE。 */
6
+ export const PG_UNIQUE_VIOLATION_SQLSTATE = "23505";
7
+ /** 非对象(null / undefined / 字符串 / 数字)一律否——谓词自身绝不成为新的失败源。 */
8
+ function keyOf(err, key) {
9
+ if (err === null || typeof err !== "object")
10
+ return undefined;
11
+ return Reflect.get(err, key);
12
+ }
13
+ /** MySQL/TiDB 的唯一键冲突:`code === "ER_DUP_ENTRY"` **或** `errno === 1062`(同一件事的两种写法)。 */
14
+ export function isMysqlDupKeyError(err) {
15
+ return String(keyOf(err, "code")) === MYSQL_ER_DUP_ENTRY_CODE || Number(keyOf(err, "errno")) === MYSQL_ER_DUP_ENTRY_ERRNO;
16
+ }
17
+ /** PostgreSQL 的唯一键冲突:SQLSTATE `23505`(node-pg 挂在 `code` 上)。 */
18
+ export function isPgUniqueViolation(err) {
19
+ return String(keyOf(err, "code")) === PG_UNIQUE_VIOLATION_SQLSTATE;
20
+ }
21
+ /** 方言分派口——双方言店的调用形(`isDupKeyError(this.db.dialect, e)`)。 */
22
+ export function isDupKeyError(dialect, err) {
23
+ return dialect === "tidb" ? isMysqlDupKeyError(err) : isPgUniqueViolation(err);
24
+ }
25
+ //# sourceMappingURL=sql-errors.js.map
@@ -1,9 +1,9 @@
1
1
  import { emitLeafAdvance } from "../session-leaf-bus.js";
2
2
  import { BaseSessionStorage, SessionError, leafIdAfterEntry, normalizePromptEpoch, } from "@sema-agent/core";
3
3
  import { toIso } from "./sql-row-helpers.js";
4
- function isDupKey(err) {
5
- return Boolean(err) && err.code === "ER_DUP_ENTRY";
6
- }
4
+ import { isMysqlDupKeyError } from "./sql-errors.js";
5
+ /** 判据属主 = `sql-errors.ts`(A-032 P1-①;旧形只认 `code`,只带 errno 的驱动错误漏判)。 */
6
+ const isDupKey = isMysqlDupKeyError;
7
7
  /**
8
8
  * TiDB-backed {@link BaseSessionStorage} — the durable L1 event log for one session.
9
9
  *
@@ -1,6 +1,7 @@
1
1
  import { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError } from "@sema-agent/core";
2
2
  import { foldKeyFamily, MAX_PENDING_PER_SESSION, PURGE_FENCE_MS, SERVED_FENCE_MS, } from "../orchestration/workflow-completion-inbox.js";
3
3
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
4
+ import { isDupKeyError } from "./sql-errors.js";
4
5
  /** Row cap guard: a run blob beyond this is NOT stored on update (the prior revision stays) — TiDB's
5
6
  * ~6 MiB txn-entry limit would otherwise throw the (best-effort) progress write into the workflow loop.
6
7
  * `put` (create, small) still throws on a real failure. Mirrors the journal's oversize skip-degrade. */
@@ -49,10 +50,9 @@ export class SqlWorkflowRunStore {
49
50
  await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
50
51
  }
51
52
  catch (e) {
52
- // create-once error every backend throws (contract) — dup-key classification is dialect-specific:
53
- // TiDB's errno-string ER_DUP_ENTRY vs PG's SQLSTATE 23505.
54
- const isDupKey = this.db.dialect === "tidb" ? e.code === "ER_DUP_ENTRY" : e.code === "23505";
55
- if (isDupKey) {
53
+ // create-once error every backend throws (contract) — the dialect classification has a single
54
+ // owner (`sql-errors.ts`, A-032 P1-①); the old local form read only mysql2's string `code`.
55
+ if (isDupKeyError(this.db.dialect, e)) {
56
56
  throw new WorkflowRunStoreError("workflow_run.already_exists", "workflow run id already exists");
57
57
  }
58
58
  throw e;
package/dist/run-local.js CHANGED
@@ -72,6 +72,7 @@ import { memoryEngineBackendFor, memorySpecForRequest } from "./memory-scope.js"
72
72
  import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
73
73
  import { buildPricing, cappedCeiling, createTracer } from "./budget.js";
74
74
  import { createSharedRunnerDeps } from "./boot/runner-deps.js";
75
+ import { createGovernanceSeams } from "./boot/governance-seams.js";
75
76
  import { createOrgMemoryAdmissionWiring } from "./boot/org-memory.js";
76
77
  import { createPermissionDeniedMeter } from "./observability/tool-trace.js";
77
78
  import { createKeyResolver } from "./key-resolver.js";
@@ -580,8 +581,22 @@ export async function runLocal(argv, deps = {}) {
580
581
  * · `sharedMemoryStores`:**undefined** —— 供给面只有 SQL 形(`store-backend.ts` 明写:单机造 file 形
581
582
  * 等于给一个人的部署做「团队共享」,能力面会因此说谎)。
582
583
  */
584
+ // design/170 件B/C/D(#252):三个部署治理座席 —— 本地形与 server 形**同一只装配口**。为什么本地形也要
585
+ // 有:`COMPLIANCE_PROFILE` / `LOCKED_CONFIG_KEYS` / `RETENTION_MAX_AGE_DAYS` 是从同一份本地 `.env` 读出来
586
+ // 的,一个写下了档位的用户在 `sema` 与本机 HTTP 服务上必须得到同一个答案(两条腿对同一份配置两套行为
587
+ // 正是本仓在册的病族)。三键未配 = 三座席缺席 = 本腿逐字零变化。
588
+ const governanceSeams = createGovernanceSeams({
589
+ config,
590
+ logger,
591
+ retentionStores: [
592
+ { name: "sessionStore", store: sessionStore },
593
+ { name: "checkpointStore", store: fileBackend.checkpointStore },
594
+ { name: "toolResultStore", store: fileBackend.toolResultStore },
595
+ ],
596
+ });
583
597
  const sharedRunnerDeps = createSharedRunnerDeps({
584
598
  config,
599
+ governanceSeams,
585
600
  logger, // #240②:onNotice 席(共享基座键,本地形同席——结构化通告落同一 logger)
586
601
  // 交接件⑤:commit 尾注署名座进**共享基座** ⇒ 主 runner 与 subRunner 同源。
587
602
  // [931]① clay 拍:run-local = Sema 品牌本地形态,commit 尾注接 Sema 署名(core 1.300 缺省已翻转不署)。
package/dist/runs.js CHANGED
@@ -555,40 +555,6 @@ approval) {
555
555
  // Thread the owning principal so the cost tracer attributes this run's spend (incl. council/team
556
556
  // sub-tasks) to the principal's cumulative quota — mirrors the synchronous path.
557
557
  const runBody = async () => {
558
- if (verify !== undefined) {
559
- // Developer-mode verification gate (1.44): multi-round (impl → adversarial verify → fix loop), not
560
- // a stream — await the result and persist it terminal. The durable event log gets just the `done`
561
- // (the verifier subtask runs on its own session). `result.verification` carries the verdict.
562
- const result = await runWithVerification(runner, { ...spec, taskId, signal: cancelCtrl.signal }, verify);
563
- await flush();
564
- evictIfConflict(runner, spec.sessionId, result);
565
- // A nested suspend under the verify gate is mapped by core to status:"failed" carrying the capability
566
- // token — strip it before it touches the replayable log / run row (search [18] Q1b).
567
- await flushPromptManifest(); // [998]②: the verify leg has no stream — persist the manifest before the terminal
568
- const safe = await withModelUsage(stripCheckpointToken(result)); // E8: attach stats.modelUsage at terminal
569
- await append("done", { result: safe });
570
- reached = { kind: "done", result: safe };
571
- await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
572
- recordDone(safe, safe.status);
573
- return;
574
- }
575
- if (cascade !== undefined) {
576
- // Cascade (1.45): cheap→strong ladder; multi-attempt cold re-runs, not a stream. Persist terminal.
577
- const result = await runCascade(runner, { ...spec, taskId, signal: cancelCtrl.signal }, cascade);
578
- await flush();
579
- evictIfConflict(runner, spec.sessionId, result);
580
- await flushPromptManifest(); // [998]②: same no-stream posture as the verify leg above
581
- const safe = await withModelUsage(stripCheckpointToken(result)); // see the verify branch; E8 modelUsage at terminal
582
- await append("done", { result: safe });
583
- reached = { kind: "done", result: safe };
584
- await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
585
- recordDone(safe, safe.status);
586
- return;
587
- }
588
- // design/47 (mid-task steering): hold the live stream handle so POST /v1/runs/:id/steer can inject a steer via
589
- // stream.steer() (drained at the next turn boundary). Only this plain-stream path is steerable — the verify/
590
- // cascade branches above return a result, not a stream (parity with the preemptable registration). Deregistered
591
- // in `finally`. Registering it AFTER the verify/cascade early-returns means those legs never enter the map.
592
558
  // core 1.154 nested-subagent live tree: opt into the forward sink — a subagent's task_progress lives in the
593
559
  // child run's ISOLATED stream (core's only emitter is parentToolCallId-gated and pushes onto the CHILD queue;
594
560
  // the top stream NEVER carries it), so this sink is the ONLY path a tick reaches the top-run consumers. 1.78
@@ -596,21 +562,52 @@ approval) {
596
562
  // forget — `++seq` is a sync increment so sink-vs-loop appends get unique seqs), not just the fleet bus —
597
563
  // previously no fleet bus wired ⇒ ticks dropped entirely, and the stream-switch task_progress case below was
598
564
  // forward-compat then — [2373]B-4:floor ^4.0.0 下它是热路径(腐注方向已翻转,勿按旧注当死支)。core swallow-guards a throwing sink.
565
+ // [3911] 定谳:per-agent tail 的**内容帧供给**由本腿 spec 的 forwardSubagentEvents 决定(core
566
+ // prepare-task 的 forward 门);缺席 ⇒ 那条流恒只有 task_progress。逐帧按**句柄**登记本腿的供给形,
567
+ // 让 tail 的 meta 帧如实声明(键为什么必须是句柄:SubagentTailBus.modeByHandle 顶注)。
568
+ //
569
+ // 🔴 **#253(core 5.33.0 #246 提货)起,这只 internals 在 verify/cascade 早返分支之前铸**:三条腿
570
+ // (verify 形 / cascade 形 / 普通流形)用**同一只**对象。5.32 及以前 core 的 `runWithVerification` /
571
+ // `runCascade` 签名里根本没有这一位,那两条腿因此对子代进度、活性打点、bg 终态通知**全盲**;5.33.0
572
+ // 开了尾可选 `internals` 座位并逐字转发给它驱动的每一条腿(实现腿/验证器腿/修复腿/每一级 rung),
573
+ // 于是这里只需要把既有供给链**接上去**,不新铸第二条链(黑盒判据见
574
+ // `test/issue190-status-wire-disclosure.test.ts` §4)。
575
+ // ⚠️ **`isolation` 一位永不传**:core 对 `internals.isolation` 在 verify/cascade 门上双相位响亮拒
576
+ // (`config.verify_internals_invalid` —— 这道门一次跑好几条腿,而 isolation 是 per-run 铸工作区),
577
+ // 本仓从不铸这一位,座位对象里也不许出现它。
578
+ const tailContentMode = spec.forwardSubagentEvents === true ? "on" : "progress_only";
599
579
  const fwdInternals = {
580
+ // #253:活性打点 —— 「只回结果」形没有事件循环,`recordTurnActivity` 此前只挂在 LedgerSink 的
581
+ // append 链上(那条链在 verify/cascade 腿上整段任务只响一次:终局 done)。core 的 onActivity 在
582
+ // 每次工具 start/end 同步回调,于是一条长跑的 verify 腿不再被 `GET /v1/runs/:id` 的
583
+ // `msSinceLastActivity` / 409 冲突体读成僵死。普通流形不经它(那条腿的打点在 sink 里,同一个函数,
584
+ // 重复打点无害但也无必要——本键对三腿一律在场,少一条判别分支)。
585
+ onActivity: () => recordTurnActivity(taskId),
600
586
  onForwardEvent: (e) => {
601
587
  fleetPublisher?.onForwardEvent(e);
602
588
  // C1 (core 1.219): a delegated child's tool lifecycle + task_progress ticks persist through the SHARED
603
589
  // sink (same whitelist+redact builders as the top stream — §E1, forwarded events are UNTRUSTED RAW;
604
590
  // fire-and-forget with sync seq allocation). Rationale + the deliberate per-chunk-delta omission live
605
591
  // on LedgerSink.onForwardEvent.
592
+ //
593
+ // 🔴 **别在这里加「腿结束就不落账」的围栏**(#253 施工期实测:加了当场打红 `runs-coverage` 的
594
+ // [1840]§三定性钉)。那条钉是**取证结论**,不是习惯:`run_in_background` 收养形子代的**整个**
595
+ // 生命周期都在父 run 终态之后,live events 流按设计 terminal break —— 帧照落账,消费端 done 后
596
+ // 重拉即全见。撕掉 sink = 对那一族子代**真丢帧**(cli [1839] 轮②「子代内层零转发」的病因分析里
597
+ // 写死了这一条)。codex 对抗复审 R2 [medium] 提的正是「终局后不该再落行」,**验真后驳回**:
598
+ // 本仓已有属主裁定 + 复现器 + 防回归钉,不能因为一次静态复审把它反过来。
606
599
  sink.onForwardEvent(e);
607
600
  // S2 live tail(core 1.370 bgAgentId,[1520]{core}):bg 子代帧按句柄分发给 per-agent 订阅者
608
601
  // (sync 腿 twin 在 http/server.ts)。hasSubscribers 快门=没人听不投影;投影一次 N 订阅者共享。
609
602
  const bg = e.bgAgentId;
610
- if (bg !== undefined && defaultSubagentTailBus.hasSubscribers(bg)) {
611
- const f = projectTailFrame(e);
612
- if (f)
613
- defaultSubagentTailBus.publish(bg, f);
603
+ if (bg !== undefined) {
604
+ // 供给形登记在 hasSubscribers 快门**之前**:没人在听时也要学,否则晚开的 tail 读不到。
605
+ defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
606
+ if (defaultSubagentTailBus.hasSubscribers(bg)) {
607
+ const f = projectTailFrame(e);
608
+ if (f)
609
+ defaultSubagentTailBus.publish(bg, f);
610
+ }
614
611
  }
615
612
  },
616
613
  // (durable half is the deployment's job): observe EVERY background
@@ -631,6 +628,9 @@ approval) {
631
628
  if (n.task_type === "workflow")
632
629
  return;
633
630
  // S2 复审 #3(sync 腿同注):中途终态信号。
631
+ // [3911] 终态即撤供给形登记:同一 a* 句柄可进入下一个**复活周期**,而新周期的宿主腿模式
632
+ // 可能相反——不撤会把上一周期的答案报给新周期的首帧前窗口(理由全文见 forgetHandleContentMode)。
633
+ defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
634
634
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
635
635
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
636
636
  }
@@ -678,6 +678,44 @@ approval) {
678
678
  }
679
679
  : {}),
680
680
  };
681
+ if (verify !== undefined) {
682
+ // Developer-mode verification gate (1.44): multi-round (impl → adversarial verify → fix loop), not
683
+ // a stream — await the result and persist it terminal. The durable event log gets just the `done`
684
+ // (the verifier subtask runs on its own session). `result.verification` carries the verdict.
685
+ // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给实现腿 + 验证器腿 + 每一轮修复腿。
686
+ const result = await runWithVerification(runner, { ...spec, taskId, signal: cancelCtrl.signal }, verify, fwdInternals);
687
+ await flush();
688
+ evictIfConflict(runner, spec.sessionId, result);
689
+ // A nested suspend under the verify gate is mapped by core to status:"failed" carrying the capability
690
+ // token — strip it before it touches the replayable log / run row (search [18] Q1b).
691
+ await flushPromptManifest(); // [998]②: the verify leg has no stream — persist the manifest before the terminal
692
+ const safe = await withModelUsage(stripCheckpointToken(result)); // E8: attach stats.modelUsage at terminal
693
+ await append("done", { result: safe });
694
+ reached = { kind: "done", result: safe };
695
+ await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
696
+ recordDone(safe, safe.status);
697
+ return;
698
+ }
699
+ if (cascade !== undefined) {
700
+ // Cascade (1.45): cheap→strong ladder; multi-attempt cold re-runs, not a stream. Persist terminal.
701
+ // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给**每一级 rung**(顺序跑,一只 sink
702
+ // 按梯序拼接收到各级的帧 —— core 的 ATTRIBUTION 注写明 ToolActivity 自身不带 rung 身份)。
703
+ const result = await runCascade(runner, { ...spec, taskId, signal: cancelCtrl.signal }, cascade, fwdInternals);
704
+ await flush();
705
+ evictIfConflict(runner, spec.sessionId, result);
706
+ await flushPromptManifest(); // [998]②: same no-stream posture as the verify leg above
707
+ const safe = await withModelUsage(stripCheckpointToken(result)); // see the verify branch; E8 modelUsage at terminal
708
+ await append("done", { result: safe });
709
+ reached = { kind: "done", result: safe };
710
+ await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
711
+ recordDone(safe, safe.status);
712
+ return;
713
+ }
714
+ // design/47 (mid-task steering): hold the live stream handle so POST /v1/runs/:id/steer can inject a steer via
715
+ // stream.steer() (drained at the next turn boundary). Only this plain-stream path is steerable — the verify/
716
+ // cascade branches above return a result, not a stream (parity with the preemptable registration). Deregistered
717
+ // in `finally`. Registering it AFTER the verify/cascade early-returns means those legs never enter the map.
718
+ // (供给座席 `fwdInternals` 已在本函数顶部铸好并被三条腿共用 —— 见那里的顶注。)
681
719
  const stream = runner.runTaskStream({ ...spec, taskId, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, undefined, fwdInternals);
682
720
  steerableStreamRef = stream; // held for the finally's identity-guarded delete (1.78 review LOW)
683
721
  steerable?.set(taskId, stream);
@@ -22,7 +22,7 @@
22
22
  * `runtimeCapsResolver`) is a DEFERRED v2.
23
23
  * When the gate is closed, a sent `mcpServers` is IGNORED (advertised via `capabilities.mcpInjection`).
24
24
  */
25
- import type { McpServerSpec } from "@sema-agent/core";
25
+ import { type CompliancePosture, type LockedKey, type McpServerSpec } from "@sema-agent/core";
26
26
  /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */
27
27
  export declare const MAX_REQUEST_MCP_SERVERS = 32;
28
28
  /**
@@ -34,6 +34,8 @@ export declare const MAX_REQUEST_MCP_SERVERS = 32;
34
34
  */
35
35
  export declare function mcpInjectionHonored(config: {
36
36
  requirePrincipal?: boolean;
37
+ lockedConfigKeys?: readonly LockedKey[];
38
+ compliancePosture?: CompliancePosture;
37
39
  }): boolean;
38
40
  /**
39
41
  * Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the
@@ -43,6 +45,24 @@ export declare function validateRequestMcp(raw: unknown): {
43
45
  ok: McpServerSpec[];
44
46
  dropped: string[];
45
47
  } | null;
48
+ /**
49
+ * design/170 件C §4.3 —— **同步**拒面:部署锁了 `mcp` 而请求仍带 `mcpServers` ⇒ 400 `config.locked_key`。
50
+ *
51
+ * 为什么在这里也拒一次(与 core 的 prepare 门不是「两处裁决」):设计稿把裁决点定在 server 治理层,
52
+ * core 的 preflight 执行的是**同一条**判据的引擎侧终局形。二者语义等价、位置不同 —— 一个请求根本不该
53
+ * 进 core 才发现自己被锁,而消费端拿到一条同步 4xx 比拿到一个跑起来又终态失败的任务可诊断得多。
54
+ *
55
+ * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是
56
+ * design/170 §2.3 讲的那种把判决变成探针的形)。键缺席 / 显式 `null` 都不是占位。
57
+ *
58
+ * ⚠️ **不看车道**:多租户腿本来就会忽略 body MCP(`mcpInjectionHonored` 关),但锁在场时忽略是错的
59
+ * 失败方向 —— operator 声明了「本部署不收任务自带 MCP」,那就该说出来,而不是安静地当没看见。
60
+ *
61
+ * ⚠️ **resume 腿同拒,这是刻意的**(与本仓「4xx 会砖存量 task」的一般姿势有意分歧):锁上之后,一条存量
62
+ * 任务的 body 里带着 mcpServers 就再也过不了 core 的 preflight —— 放它进去只会换来一个跑起来又终态失败的
63
+ * 任务。同样是失败,同步 400 至少是**立刻可诊断**的那一种,而且拒得出理由(锁在哪、怎么改)。
64
+ */
65
+ export declare function assertRequestMcpUnlocked(bodyMcp: unknown, lockedKeys: ReadonlySet<LockedKey>): void;
46
66
  /**
47
67
  * Merge GATED per-request MCP servers OVER the deployment baseline (center/config). The baseline WINS on a name
48
68
  * clash — a caller can ADD servers but can never SHADOW a configured one (the security baseline is authoritative,
@@ -60,6 +80,7 @@ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined,
60
80
  provider?: string;
61
81
  };
62
82
  requirePrincipal?: boolean;
83
+ lockedConfigKeys?: readonly LockedKey[];
63
84
  }, logger?: {
64
85
  info?(m: string, meta?: unknown): void;
65
86
  warn(m: string, meta?: unknown): void;
package/dist/task-mcp.js CHANGED
@@ -1,3 +1,29 @@
1
+ /**
2
+ * [TOC MCP seam, R3] — project the TOC client's local
3
+ * `.mcp.json` (`TaskRequest.mcpServers`) into the engine, so a `sema` run honors the user's own MCP servers
4
+ * (CC-parity "配置即用" local-first), instead of ONLY the center-config MCP. Mirrors the `cwd` seam (task-cwd.ts).
5
+ *
6
+ * 🔒 SECURITY — per-request MCP is the constitutional "untrusted-client tool injection" red line, because an MCP
7
+ * server is a TOOL SOURCE and an stdio transport is **arbitrary command execution**. The decisive fact (verified):
8
+ * core materializes stdio MCP with `StdioClientTransport`, which spawns the command as a child of **the WORKER
9
+ * (core's process), NOT the execution env** — so an stdio MCP runs on the worker host REGARDLESS of the exec lane
10
+ * (host/e2b/docker). Therefore the trust boundary is NOT "the host execution lane" (that's where the agent's TOOL
11
+ * CALLS run, irrelevant to MCP) — it is **"is the worker single-user, i.e. is the requester the super-admin?"** =
12
+ * `requirePrincipal !== true`. core's ruling (R3): the SERVICE is core's caller-trust-root; body→`TaskSpec.mcp` is a
13
+ * trust decision the service makes, never a passthrough.
14
+ * - SINGLE-USER deployment (`requirePrincipal===false`): the requester IS the super-admin of their own worker, so
15
+ * their `.mcp.json` is authoritative — exactly what CC does on the user's machine. This holds across EVERY
16
+ * execution lane (host / E2B / k8s / local-docker), so a TOC user who switches execution local↔cloud keeps their
17
+ * MCP honored seamlessly (TOC↔cloud is a spectrum, not a wall — clay 2026-06-29: TOB = single-user + people-mgmt).
18
+ * The caller being the trust root, `toolAxes` (authoritative effect overrides) + `elicitation` are their own choices.
19
+ * - MULTI-TENANT deployment (`requirePrincipal===true`): a tenant must NOT make the SHARED worker run an arbitrary
20
+ * command (RCE) or connect to a body-chosen URL (SSRF). Gate CLOSED → body MCP ignored; the fleet gets MCP only
21
+ * through center config refs. Per-principal http-remote injection (a center `allowMcpInjection` entitlement via
22
+ * `runtimeCapsResolver`) is a DEFERRED v2.
23
+ * When the gate is closed, a sent `mcpServers` is IGNORED (advertised via `capabilities.mcpInjection`).
24
+ */
25
+ import { resolveComplianceDenies } from "@sema-agent/core";
26
+ import { HttpError } from "./security.js";
1
27
  /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */
2
28
  export const MAX_REQUEST_MCP_SERVERS = 32;
3
29
  /**
@@ -8,6 +34,21 @@ export const MAX_REQUEST_MCP_SERVERS = 32;
8
34
  * run an arbitrary command / connect to an arbitrary URL).
9
35
  */
10
36
  export function mcpInjectionHonored(config) {
37
+ // design/170 件C(codex 对抗复审 F2,验真后采纳):**锁也是这条轴的一票否决**。旧谓词只看租户轴,
38
+ // 于是「单用户 + `mcp` 锁」的部署会广告 `mcpInjection:true`,而每一个照此渲出 affordance 的调用方发过
39
+ // 来都吃 `config.locked_key` 400 —— 本仓 capabilities 面的立面承诺(「says yes ⟺ route works」)当场破。
40
+ // 判据合取在这里而不是在能力位那行,是因为这只谓词是**唯一**属主(能力位与请求腿都读它)。
41
+ if (config.lockedConfigKeys?.includes("mcp") === true)
42
+ return false;
43
+ // 件B 同族(R2-F2):合规档位禁了 `mcp_servers` 时,core 对**带 spec.mcp 的每一条腿**整拒 prepare
44
+ // (`config.compliance_denied`,亲核 prepare-task 的 compliance 段)。同样是「广告了却用不了」,同样一票否决。
45
+ //
46
+ // ⚠️ **刻意不 catch**(#191 门② SHAPE C 当场逮到的那一版已撤):`resolveComplianceDenies` 对闭集外的值
47
+ // 是 fail-loud 的,而这个档位在到这里之前已经被验过两遍(config 解析层 + boot 装配层),所以它在本站点
48
+ // 抛不出来。真抛了 = 有人绕过那两道门往 config 里塞了一个解析不了的档位 —— 那种时候**响亮**(路由 500)
49
+ // 才是对的方向,吞成一个布尔位等于让一台档位坏掉的部署继续对外广告能力位(安全轴禁静默兜底)。
50
+ if (config.compliancePosture !== undefined && resolveComplianceDenies(config.compliancePosture).has("mcp_servers"))
51
+ return false;
11
52
  return config.requirePrincipal !== true;
12
53
  }
13
54
  function isStringRecord(v) {
@@ -99,6 +140,32 @@ export function validateRequestMcp(raw) {
99
140
  dropped.push(`(+${raw.length - MAX_REQUEST_MCP_SERVERS} over cap of ${MAX_REQUEST_MCP_SERVERS})`);
100
141
  return { ok, dropped };
101
142
  }
143
+ /**
144
+ * design/170 件C §4.3 —— **同步**拒面:部署锁了 `mcp` 而请求仍带 `mcpServers` ⇒ 400 `config.locked_key`。
145
+ *
146
+ * 为什么在这里也拒一次(与 core 的 prepare 门不是「两处裁决」):设计稿把裁决点定在 server 治理层,
147
+ * core 的 preflight 执行的是**同一条**判据的引擎侧终局形。二者语义等价、位置不同 —— 一个请求根本不该
148
+ * 进 core 才发现自己被锁,而消费端拿到一条同步 4xx 比拿到一个跑起来又终态失败的任务可诊断得多。
149
+ *
150
+ * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是
151
+ * design/170 §2.3 讲的那种把判决变成探针的形)。键缺席 / 显式 `null` 都不是占位。
152
+ *
153
+ * ⚠️ **不看车道**:多租户腿本来就会忽略 body MCP(`mcpInjectionHonored` 关),但锁在场时忽略是错的
154
+ * 失败方向 —— operator 声明了「本部署不收任务自带 MCP」,那就该说出来,而不是安静地当没看见。
155
+ *
156
+ * ⚠️ **resume 腿同拒,这是刻意的**(与本仓「4xx 会砖存量 task」的一般姿势有意分歧):锁上之后,一条存量
157
+ * 任务的 body 里带着 mcpServers 就再也过不了 core 的 preflight —— 放它进去只会换来一个跑起来又终态失败的
158
+ * 任务。同样是失败,同步 400 至少是**立刻可诊断**的那一种,而且拒得出理由(锁在哪、怎么改)。
159
+ */
160
+ export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
161
+ if (!lockedKeys.has("mcp"))
162
+ return;
163
+ if (bodyMcp === undefined || bodyMcp === null)
164
+ return;
165
+ throw new HttpError(400, "`mcpServers` is administratively locked by this deployment (locked key \"mcp\") — task-supplied MCP servers are refused; locks are config-time and there is no request-time unlock", {
166
+ code: "config.locked_key",
167
+ });
168
+ }
102
169
  /**
103
170
  * Merge GATED per-request MCP servers OVER the deployment baseline (center/config). The baseline WINS on a name
104
171
  * clash — a caller can ADD servers but can never SHADOW a configured one (the security baseline is authoritative,
@@ -117,7 +184,10 @@ export function mergeRequestMcp(baseline, gated) {
117
184
  * unchanged. Logs an `mcp_injection_ignored` when a caller SENT servers a closed gate dropped (so the shell never
118
185
  * silently believes its `.mcp.json` took effect — it also reads `capabilities.mcpInjection`).
119
186
  */
120
- export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
187
+ export function resolveRequestMcp(baseline, bodyMcp,
188
+ // `lockedConfigKeys` 随 config 一并进(design/170 件C):`assertRequestMcpUnlocked` 已在腿首整拒,所以
189
+ // 这里的 honored 判据在锁形下不可达 —— 保留它是纵深(判据只有一只谓词,不许两处各判一半)。
190
+ config, logger) {
121
191
  if (bodyMcp === undefined || bodyMcp === null)
122
192
  return baseline;
123
193
  const honored = mcpInjectionHonored(config);