@sema-agent/server 7.128.0 → 7.129.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 (105) hide show
  1. package/MIGRATION.md +140 -0
  2. package/USAGE.md +114 -18
  3. package/dist/approval-card.d.ts +53 -26
  4. package/dist/approval-card.js +40 -11
  5. package/dist/approval-crash-convergence.js +4 -4
  6. package/dist/bake-runner/main.js +2 -1
  7. package/dist/boot/execution-env.js +1 -0
  8. package/dist/boot/memory-consolidation.d.ts +46 -16
  9. package/dist/boot/memory-consolidation.js +15 -8
  10. package/dist/boot/side-query-lane.d.ts +7 -20
  11. package/dist/boot/side-query-lane.js +0 -24
  12. package/dist/boot/stage-01-config.js +3 -0
  13. package/dist/boot/stage-06-runners.js +1 -1
  14. package/dist/boot/stores.js +2 -2
  15. package/dist/boot/web-admin.js +17 -10
  16. package/dist/boot/webfetch-summarize-lane.d.ts +10 -7
  17. package/dist/boot/webfetch-summarize-lane.js +2 -3
  18. package/dist/brain.js +6 -9
  19. package/dist/capabilities/builtin-tools.d.ts +2 -2
  20. package/dist/capabilities/engine-capabilities.d.ts +14 -0
  21. package/dist/capabilities/engine-capabilities.js +12 -0
  22. package/dist/capability-facts.d.ts +3 -1
  23. package/dist/capability-facts.js +1 -0
  24. package/dist/config-catalog.js +5 -1
  25. package/dist/config-types.d.ts +7 -3
  26. package/dist/config.js +7 -1
  27. package/dist/egress-redirect.d.ts +12 -0
  28. package/dist/egress-redirect.js +7 -0
  29. package/dist/entry-mode-clamp-notice.d.ts +30 -0
  30. package/dist/entry-mode-clamp-notice.js +13 -0
  31. package/dist/exec-bounds.d.ts +69 -0
  32. package/dist/exec-bounds.js +37 -0
  33. package/dist/fleet/fleet-bus.d.ts +7 -0
  34. package/dist/fleet/fleet-bus.js +1 -0
  35. package/dist/hooks/hook-llm.js +6 -2
  36. package/dist/host-lsp-manager.d.ts +3 -3
  37. package/dist/http/admission.js +5 -2
  38. package/dist/http/resume-legs.js +46 -27
  39. package/dist/http/route-ctx.d.ts +1 -1
  40. package/dist/http/routes/approvals-assistant.js +6 -4
  41. package/dist/http/routes/capabilities.js +1 -0
  42. package/dist/http/routes/session-sync.js +3 -4
  43. package/dist/http/routes/sessions.js +4 -17
  44. package/dist/http/send.d.ts +1 -1
  45. package/dist/http/send.js +4 -3
  46. package/dist/http/wire-types.d.ts +3 -2
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/observability/fail-open.d.ts +7 -3
  50. package/dist/observability/fail-open.js +7 -3
  51. package/dist/observability/secret-env-scrub.d.ts +37 -3
  52. package/dist/observability/secret-env-scrub.js +2 -2
  53. package/dist/parked-decide.js +5 -2
  54. package/dist/plugins/memory-embedder.js +5 -0
  55. package/dist/plugins/one-time-migrations.d.ts +180 -9
  56. package/dist/plugins/one-time-migrations.js +315 -3
  57. package/dist/plugins/pg-pool.js +3 -1
  58. package/dist/plugins/remote-env-e2b.d.ts +31 -9
  59. package/dist/plugins/remote-env-e2b.js +77 -26
  60. package/dist/plugins/remote-env-host.d.ts +17 -20
  61. package/dist/plugins/remote-env-host.js +20 -30
  62. package/dist/plugins/remote-env-k8s.d.ts +12 -4
  63. package/dist/plugins/remote-env-k8s.js +22 -31
  64. package/dist/plugins/session-policy-store-sql.d.ts +16 -4
  65. package/dist/plugins/session-policy-store-sql.js +17 -20
  66. package/dist/plugins/session-rules-column.d.ts +16 -0
  67. package/dist/plugins/session-rules-column.js +6 -0
  68. package/dist/plugins/tidb-pool.js +4 -1
  69. package/dist/read-face-posture.d.ts +2 -1
  70. package/dist/request-value-closure.js +1 -1
  71. package/dist/retired-card-words.d.ts +17 -0
  72. package/dist/retired-card-words.js +49 -0
  73. package/dist/rule-entry-list.js +2 -0
  74. package/dist/run-local.js +10 -3
  75. package/dist/server-secret-env.d.ts +77 -85
  76. package/dist/server-secret-env.js +40 -26
  77. package/dist/session-policy-wire.d.ts +61 -124
  78. package/dist/session-policy-wire.js +59 -121
  79. package/dist/session-sync.d.ts +8 -6
  80. package/dist/session-sync.js +6 -5
  81. package/dist/spec-fields.d.ts +3 -2
  82. package/dist/standing-answer-grant.d.ts +17 -9
  83. package/dist/standing-answer-grant.js +11 -8
  84. package/dist/task-settings.d.ts +6 -1
  85. package/dist/task-settings.js +1 -1
  86. package/dist/tool-approval.d.ts +14 -7
  87. package/dist/tool-approval.js +23 -18
  88. package/dist/trace/core-keyset-guard.d.ts +3 -3
  89. package/dist/trace/engine-notice-wire.d.ts +1 -1
  90. package/dist/trace/engine-notice-wire.js +1 -0
  91. package/dist/trace/project.d.ts +7 -4
  92. package/dist/trace/project.js +6 -75
  93. package/dist/trace/tool-output-media.d.ts +2 -1
  94. package/dist/trace/tool-output-media.js +3 -25
  95. package/dist/web-admin-client/apply-effective.js +28 -11
  96. package/dist/web-admin-client/facade.d.ts +1 -1
  97. package/dist/web-admin-client/facade.js +1 -1
  98. package/dist/web-admin-client/read-face.d.ts +73 -20
  99. package/dist/web-admin-client/read-face.js +76 -36
  100. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  101. package/dist/web-admin-client/skills-mcp.js +1 -1
  102. package/dist/web-admin-client/types.d.ts +3 -3
  103. package/package.json +3 -3
  104. package/dist/mcp-tool-axes.d.ts +0 -62
  105. package/dist/mcp-tool-axes.js +0 -47
@@ -1,12 +1,39 @@
1
+ import { SESSION_RULES_GRAMMAR, compileSessionRuleBucket, readSessionRulesShape } from "@sema-agent/core";
1
2
  import { SCHEMA_STATEMENTS, acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
3
  import { PG_SCHEMA_STATEMENTS, acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
4
  import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
4
5
  import { ensureIndexes } from "./ensure-index.js";
5
- import { decodeJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn } from "./sql-json-column.js";
6
+ import { decodeJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn, readOpaqueJsonValueColumn } from "./sql-json-column.js";
6
7
  import { SESSION_META_INCARNATION_COLUMN, mintSessionIncarnation } from "../session-incarnation.js";
7
8
  import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR, StoreBootRefusalError } from "./store-contracts.js";
8
9
  import { PERMISSION_RULE_TABLE, purgeUndurableScopeRows } from "./permission-rule-store-sql.js";
9
10
  import { SCHEMA_MISMATCH_ERROR_CODE } from "./schema-consistency.js";
11
+ import { SESSION_RULES_COLUMN } from "./session-rules-column.js";
12
+ import { readJsonObjectColumn } from "./sql-json-column.js";
13
+ import { retireStoredCardWords } from "../retired-card-words.js";
14
+ import { redactSecrets } from "../trace/redact.js";
15
+ const RETIRED_SESSION_RULE_BUCKETS = ["commandAllow", "commandDeny"];
16
+ export function buildSessionRuleReceipts() {
17
+ return { rows: [], renamed: false };
18
+ }
19
+ export class OneTimeMigrationInterruptedError extends Error {
20
+ receipts;
21
+ constructor(cause, receipts) {
22
+ super(`${cause instanceof Error ? cause.message : String(cause)} — one-time migration M17 had already committed changes whose receipts the next boot cannot re-derive ` +
23
+ "(the rows below are rewritten, so a retry finds nothing left to remove from them) — record them before retrying: " +
24
+ receipts.sessionRuleRowsWithOldGrammarEntries.map((r) => `${r.policyKey} (session ${r.sessionId}): toolAllowDropped=${JSON.stringify(r.toolAllowDropped)}`).join("; "), { cause });
25
+ this.name = "OneTimeMigrationInterruptedError";
26
+ this.receipts = receipts;
27
+ }
28
+ }
29
+ function interruptedWith(cause, receipts) {
30
+ if (receipts.rows.length === 0)
31
+ return cause;
32
+ return new OneTimeMigrationInterruptedError(cause, {
33
+ ...(receipts.renamed ? { sessionRuleColumn: "renamed" } : {}),
34
+ sessionRuleRowsWithOldGrammarEntries: [...receipts.rows],
35
+ });
36
+ }
10
37
  export const ONE_TIME_MIGRATIONS = [
11
38
  {
12
39
  id: "M4",
@@ -178,6 +205,42 @@ export const ONE_TIME_MIGRATIONS = [
178
205
  "models 域降级、rosters 整份读不出 —— 拒句点名迁移 id;出路是运维手跑契约命令行(契约删族之后只剩手改)",
179
206
  reportKeys: [],
180
207
  },
208
+ {
209
+ id: "M17",
210
+ introducedIn: "7.129.0",
211
+ releasedOn: "2026-10-15",
212
+ removableAfter: "2026-11-14",
213
+ removal: "删 migrateOn 的 M17 步(`migrateSessionRuleGrammar`:`session_policy` 在场探针、旧名 → 进行中的名 → 判读 → 终名三段、退役命令桶扫描与点名)与 " +
214
+ "`retiredSessionRuleBucketsOf` / `RetiredSessionRuleBucket` / `RETIRED_SESSION_RULE_BUCKETS` / `RETIRED_SESSION_RULES_COLUMN` / `OLD_GRAMMAR_SESSION_RULES_COLUMN` / `SESSION_RULES_SCAN_PAGE` / `SessionRuleRowRetiredBuckets` / " +
215
+ "`oldGrammarEntriesOf` / `OldGrammarEntries` / `judgeOldGrammarRows` / `renameSessionRulesColumnSql` / `scanSessionRuleRows` / `SessionRuleScanRow` / " +
216
+ "`RETIRED_BUCKET_PATTERNS` / `SessionRuleRowOldGrammarEntries` / 收据账 `SessionRuleReceipts` / `buildSessionRuleReceipts` / `OneTimeMigrationReceipts` / " +
217
+ "`OneTimeMigrationInterruptedError` / `interruptedWith`(`migrateOn` 外层的那一道 try / catch)/ `logOneTimeMigrationsInterrupted` / `runAndLogOneTimeMigrations` 里的失败日志臂 / `logSessionRuleOldGrammarRows`;" +
218
+ "报告、日志行(含逐行的 `session_rule_row_retired_bucket` 与 `session_rule_row_old_grammar_entries`)与 `NO_SQL_MIGRATIONS` 的 `sessionRuleColumn` / " +
219
+ "`sessionRuleRowsWithRetiredBuckets` / `sessionRuleRowsWithOldGrammarEntries`;" +
220
+ "**列名本身留在中央 DDL 里**(`session-rules-column.ts`,它不是迁移的一部分)",
221
+ afterRemoval: "① 列还叫 `rules`、或停在进行中的名 `rule_entries_old_grammar` 的存量库(跳过了带本条的版本直接升 / 判读没走完就跳版):起服列册探针 `schema-consistency.ts`(S-547)拒启 " +
222
+ "`config.schema_mismatch`(缺 `rule_entries`、多那一列)—— " +
223
+ "出路是先在一只带本条的版本上起服一次;② 行里仍带退役命令桶:SQL 店读回照旧答 `corrupt`(引擎的形读法点名那只桶,与本条在场时同一个结局),只是起服日志不再逐行点名 —— " +
224
+ "运维在库里把那一行改写成引擎文法或删掉它(删会话即删规则);③ 跳过本条直接升的库同样没有改名那一拍的条目判读 —— 由 ① 的拒启接住(出路同 ①)",
225
+ reportKeys: ["sessionRuleColumn", "sessionRuleRowsWithRetiredBuckets", "sessionRuleRowsWithOldGrammarEntries"],
226
+ },
227
+ {
228
+ id: "M18",
229
+ introducedIn: "7.129.0",
230
+ releasedOn: "2026-10-15",
231
+ removableAfter: "2026-11-14",
232
+ removal: "删 migrateOn 的 M18 步(`migrateRetiredCardWords` 的存卡扫描、`migrateRetiredCardWordsInCardJson`、" +
233
+ "`RETIRED_CARD_WORD_PATTERNS` / `RETIRED_CARD_WORD_BATCH`);报告、日志行与 `NO_SQL_MIGRATIONS` 的 `retiredCardWordAskRowsRewritten` / " +
234
+ "`retiredCardWordRowsUnreadable`;**连同读侧半场一起删**:`src/retired-card-words.ts` 整只文件、" +
235
+ "`approval-card.ts` 里 `readStoredCardEnvelope` 的改写那一步与 `currentParkArm`(两读者 `parkRowFacts` / `tool-approval.ts` `parkRowStandingAnswerFacts` " +
236
+ "回到直读传入的那一只)与 `noteRetiredCardWordRead`、fail-open tag `server.approval-card.retired-card-word-read`(`observability/fail-open.ts` 与 " +
237
+ "`docs/FAIL-OPEN-CENSUS.md` 那一行)、`test/retired-card-words-m18.test.ts`(唯一入口那一格改钉到 `approval-card.test.ts`)",
238
+ afterRemoval: "既有坏行臂接住(响亮,不再改写):仍带退役词的存卡(跳过了带本条的版本直接升的库、窗关之后还在写的 7.128.x 副本)整卡读不出 —— 断连重放跳过 + 一次 warn、" +
239
+ "幂等重入走 park、回决口按要人;仍带它们的待决停驻行(把 `APPROVAL_TERMINAL_BACKSTOP_MS` 调长的部署、local 车道引擎 File 店里窗关仍未决的 —— 停驻行从来不由本服务改写," +
240
+ "窗内靠读侧改写读,窗关后读侧改写删)由本服务的读面按坏行处置:运维队列行缺那一位 + `server.approval-card.closed-word-out-of-set` 计数、耐久 `/decide` 的 remember 按强制(只放行这一次)。" +
241
+ "引擎续跑不受影响:续跑入口读行摘要、不按今日闭集重判停驻行上的这两座,行字节引擎怎么写的还是怎么在",
242
+ reportKeys: ["retiredCardWordAskRowsRewritten", "retiredCardWordRowsUnreadable"],
243
+ },
181
244
  ];
182
245
  export function migrationWindowOpen(window, today) {
183
246
  return window.removableAfter === null || today <= window.removableAfter;
@@ -193,6 +256,11 @@ export const NO_SQL_MIGRATIONS = {
193
256
  undurableScopeRuleRows: [],
194
257
  undurableScopeRuleBucketsDeferred: 0,
195
258
  undurableScopeRuleBucketsUnreadable: [],
259
+ sessionRuleColumn: "file-store",
260
+ sessionRuleRowsWithRetiredBuckets: [],
261
+ sessionRuleRowsWithOldGrammarEntries: [],
262
+ retiredCardWordAskRowsRewritten: 0,
263
+ retiredCardWordRowsUnreadable: 0,
196
264
  };
197
265
  const AUDIT_TABLE = "device_audit";
198
266
  const AUDIT_DETAIL_COLUMN = "detail";
@@ -318,6 +386,15 @@ async function withPgLock(pool, run) {
318
386
  }
319
387
  }
320
388
  async function migrateOn(db, dialect) {
389
+ const receipts = buildSessionRuleReceipts();
390
+ try {
391
+ return await migrateStepsOn(db, dialect, receipts);
392
+ }
393
+ catch (e) {
394
+ throw interruptedWith(e, receipts);
395
+ }
396
+ }
397
+ async function migrateStepsOn(db, dialect, receipts) {
321
398
  const auditTablePresent = await tableExists(db, dialect, AUDIT_TABLE);
322
399
  const auditDetailJsonNullRows = !auditTablePresent
323
400
  ? 0
@@ -331,7 +408,9 @@ async function migrateOn(db, dialect) {
331
408
  ? "UPDATE task_run SET error_code = ? WHERE status = 'failed' AND error_code IS NULL AND (error = ? OR error LIKE ?)"
332
409
  : "UPDATE task_run SET error_code = $1 WHERE status = 'failed' AND error_code IS NULL AND (error = $2 OR error LIKE $3)", [RUN_HOLDER_DEATH_CODES.orphaned, RUN_ORPHANED_ERROR, `${RUN_ORPHANED_ERROR} (%`]));
333
410
  const { migrated: allowRuleShadowRowsMigrated, unreadable: allowRuleShadowRowsUnreadable } = await migrateShadowedRuleKey(db, dialect);
411
+ const { askRows: retiredCardWordAskRowsRewritten, unreadable: retiredCardWordRowsUnreadable } = await migrateRetiredCardWords(db, dialect);
334
412
  const { key: workflowParkIndexKey, carried: workflowParkIndexRowsCarried } = await reshapeWorkflowParkIndex(db, dialect);
413
+ const { sessionRuleColumn, sessionRuleRowsWithRetiredBuckets, sessionRuleRowsWithOldGrammarEntries } = await migrateSessionRuleGrammar(db, dialect, receipts);
335
414
  let undurableScopeRuleRows = [];
336
415
  let undurableScopeRuleBucketsDeferred = 0;
337
416
  let undurableScopeRuleBucketsUnreadable = [];
@@ -342,8 +421,113 @@ async function migrateOn(db, dialect) {
342
421
  auditDetailJsonNullRows,
343
422
  sessionIncarnationColumn, sessionIncarnationsBackfilled, holderDeathCodeRowsBackfilled, allowRuleShadowRowsMigrated, allowRuleShadowRowsUnreadable,
344
423
  workflowParkIndexKey, workflowParkIndexRowsCarried, undurableScopeRuleRows, undurableScopeRuleBucketsDeferred, undurableScopeRuleBucketsUnreadable,
424
+ sessionRuleColumn, sessionRuleRowsWithRetiredBuckets, sessionRuleRowsWithOldGrammarEntries,
425
+ retiredCardWordAskRowsRewritten, retiredCardWordRowsUnreadable,
345
426
  };
346
427
  }
428
+ const SESSION_POLICY_TABLE = "session_policy";
429
+ const RETIRED_SESSION_RULES_COLUMN = "rules";
430
+ const OLD_GRAMMAR_SESSION_RULES_COLUMN = "rule_entries_old_grammar";
431
+ const SESSION_RULES_SCAN_PAGE = 500;
432
+ export function retiredSessionRuleBucketsOf(body) {
433
+ if (body === null || typeof body !== "object" || Array.isArray(body))
434
+ return [];
435
+ return RETIRED_SESSION_RULE_BUCKETS.filter((b) => Object.hasOwn(body, b));
436
+ }
437
+ export function oldGrammarEntriesOf(body, rev) {
438
+ if (body === null || typeof body !== "object" || Array.isArray(body))
439
+ return undefined;
440
+ const retired = retiredSessionRuleBucketsOf(body);
441
+ const buckets = Object.fromEntries(Object.entries(body).filter(([k]) => !retired.includes(k)));
442
+ const shaped = readSessionRulesShape({ ...buckets, rev });
443
+ if ("fault" in shaped)
444
+ return undefined;
445
+ const compiled = compileSessionRuleBucket(shaped.rules, "toolAllow");
446
+ if (body.grammar === undefined && compiled.inert.some((i) => i.reason === "wildcard_all"))
447
+ return { toolAllowDropped: [], wildcardMasked: true };
448
+ const earlier = new Set(compiled.inert.filter((i) => i.reason === "earlier_grammar").map((i) => i.entry));
449
+ const toolAllow = shaped.rules.toolAllow?.filter((e) => !earlier.has(e));
450
+ const toolAllowDropped = (shaped.rules.toolAllow ?? []).filter((e) => earlier.has(e));
451
+ if (toolAllowDropped.length === 0 && body.grammar !== undefined)
452
+ return { toolAllowDropped };
453
+ return { toolAllowDropped, rewritten: { ...body, ...(toolAllow !== undefined ? { toolAllow } : {}), grammar: SESSION_RULES_GRAMMAR } };
454
+ }
455
+ const RETIRED_BUCKET_PATTERNS = ['%"commandDeny"%', '%"commandAllow"%'];
456
+ async function scanSessionRuleRows(db, dialect, column, patterns, visit) {
457
+ const text = jsonColumnTextExpr(dialect, column);
458
+ const prefilter = patterns.length === 0 ? "" : ` AND (${patterns.map((_, i) => `${text} LIKE ${dialect === "tidb" ? "?" : `$${i + 2}`}`).join(" OR ")})`;
459
+ const scan = dialect === "tidb"
460
+ ? `SELECT policy_key, session_id, rev, ${jsonColumnText("tidb", column)} FROM ${SESSION_POLICY_TABLE} WHERE policy_key > ?${prefilter} ORDER BY policy_key LIMIT ${SESSION_RULES_SCAN_PAGE}`
461
+ : `SELECT policy_key, session_id, rev, ${jsonColumnText("pg", column)} FROM ${SESSION_POLICY_TABLE} WHERE policy_key > $1${prefilter} ORDER BY policy_key LIMIT ${SESSION_RULES_SCAN_PAGE}`;
462
+ let cursor = "";
463
+ for (;;) {
464
+ const page = await db.rows(scan, [cursor, ...patterns]);
465
+ for (const row of page) {
466
+ let body;
467
+ try {
468
+ body = decodeJsonColumn(row[column], readOpaqueJsonValueColumn, "session_policy.rule_entries for the M17 scans (read under its in-progress name while the old-grammar judge runs)");
469
+ }
470
+ catch (e) {
471
+ if (!isJsonColumnBodyProblem(e))
472
+ throw e;
473
+ continue;
474
+ }
475
+ await visit({ policyKey: String(row.policy_key), sessionId: String(row.session_id), rev: Number(row.rev), text: String(row[column]), body });
476
+ }
477
+ if (page.length < SESSION_RULES_SCAN_PAGE)
478
+ return;
479
+ cursor = String(page[page.length - 1].policy_key);
480
+ }
481
+ }
482
+ async function judgeOldGrammarRows(db, dialect, receipts) {
483
+ const column = OLD_GRAMMAR_SESSION_RULES_COLUMN;
484
+ const text = jsonColumnTextExpr(dialect, column);
485
+ const cas = dialect === "tidb"
486
+ ? `UPDATE ${SESSION_POLICY_TABLE} SET ${column} = ? WHERE policy_key = ? AND CAST(${text} AS BINARY) = CAST(? AS BINARY)`
487
+ : `UPDATE ${SESSION_POLICY_TABLE} SET ${column} = $1::jsonb WHERE policy_key = $2 AND ${text} = $3`;
488
+ await scanSessionRuleRows(db, dialect, column, [], async (row) => {
489
+ const read = oldGrammarEntriesOf(row.body, row.rev);
490
+ if (read?.wildcardMasked) {
491
+ receipts.rows.push({ policyKey: row.policyKey, sessionId: row.sessionId, toolAllowDropped: [], wildcardMasked: true });
492
+ return;
493
+ }
494
+ if (read?.rewritten === undefined)
495
+ return;
496
+ if ((await db.exec(cas, [JSON.stringify(read.rewritten), row.policyKey, row.text])) !== 1) {
497
+ throw new Error(`one-time migration M17: session_policy row ${row.policyKey} changed while its rule entries were being rewritten under the in-progress column ${column} — no writer can reach the ` +
498
+ "table at that moment (builds up to 7.128.x lost the column, this build's replicas wait on the boot lock), so the step refuses rather than guess");
499
+ }
500
+ if (read.toolAllowDropped.length > 0)
501
+ receipts.rows.push({ policyKey: row.policyKey, sessionId: row.sessionId, toolAllowDropped: read.toolAllowDropped.map((e) => redactSecrets(e)) });
502
+ });
503
+ }
504
+ export async function migrateSessionRuleGrammar(db, dialect, receipts) {
505
+ if (!(await tableExists(db, dialect, SESSION_POLICY_TABLE)))
506
+ return { sessionRuleColumn: "no-table", sessionRuleRowsWithRetiredBuckets: [], sessionRuleRowsWithOldGrammarEntries: [] };
507
+ const col = SESSION_RULES_COLUMN;
508
+ const has = (column) => columnExists(db, dialect, SESSION_POLICY_TABLE, column);
509
+ if (await has(RETIRED_SESSION_RULES_COLUMN)) {
510
+ await execUntil(db, renameSessionRulesColumnSql(dialect, RETIRED_SESSION_RULES_COLUMN, OLD_GRAMMAR_SESSION_RULES_COLUMN), async () => !(await has(RETIRED_SESSION_RULES_COLUMN)));
511
+ }
512
+ let sessionRuleColumn = "current";
513
+ if (await has(OLD_GRAMMAR_SESSION_RULES_COLUMN)) {
514
+ await judgeOldGrammarRows(db, dialect, receipts);
515
+ await execUntil(db, renameSessionRulesColumnSql(dialect, OLD_GRAMMAR_SESSION_RULES_COLUMN, col.name), async () => !(await has(OLD_GRAMMAR_SESSION_RULES_COLUMN)) && (await has(col.name)));
516
+ receipts.renamed = true;
517
+ sessionRuleColumn = "renamed";
518
+ }
519
+ const named = [];
520
+ await scanSessionRuleRows(db, dialect, col.name, RETIRED_BUCKET_PATTERNS, async (row) => {
521
+ const buckets = retiredSessionRuleBucketsOf(row.body);
522
+ if (buckets.length > 0)
523
+ named.push({ policyKey: row.policyKey, sessionId: row.sessionId, buckets });
524
+ });
525
+ return { sessionRuleColumn, sessionRuleRowsWithRetiredBuckets: named, sessionRuleRowsWithOldGrammarEntries: receipts.rows };
526
+ }
527
+ function renameSessionRulesColumnSql(dialect, from, to) {
528
+ const definition = SESSION_RULES_COLUMN.tidb.slice(SESSION_RULES_COLUMN.name.length);
529
+ return dialect === "tidb" ? `ALTER TABLE ${SESSION_POLICY_TABLE} CHANGE COLUMN ${from} ${to}${definition}` : `ALTER TABLE ${SESSION_POLICY_TABLE} RENAME COLUMN ${from} TO ${to}`;
530
+ }
347
531
  const WORKFLOW_PARK_INDEX_TABLE = "workflow_agent_session";
348
532
  async function primaryKeyWidth(db, dialect, table) {
349
533
  const sql = dialect === "tidb"
@@ -472,6 +656,57 @@ async function migrateShadowedRuleKey(db, dialect) {
472
656
  return { migrated, unreadable };
473
657
  }
474
658
  }
659
+ const RETIRED_CARD_WORD_PATTERNS = ['%"shadowed"%', '%"governance_unavailable"%'];
660
+ const RETIRED_CARD_WORD_BATCH = 500;
661
+ function decodeForM18(text, where) {
662
+ try {
663
+ return { ok: true, value: decodeJsonColumn(text, readJsonObjectColumn, where) };
664
+ }
665
+ catch (error) {
666
+ if (isJsonColumnBodyProblem(error))
667
+ return { ok: false, error };
668
+ throw error;
669
+ }
670
+ }
671
+ export function migrateRetiredCardWordsInCardJson(text) {
672
+ const decoded = decodeForM18(text, "approval_ask.card_json (M18 rewrite)");
673
+ if (!decoded.ok)
674
+ return { kind: "unreadable", error: decoded.error };
675
+ const next = retireStoredCardWords(decoded.value);
676
+ return next.seats.length === 0 ? { kind: "untouched" } : { kind: "rewrite", text: JSON.stringify(next.value), value: next.value };
677
+ }
678
+ async function migrateRetiredCardWords(db, dialect) {
679
+ let askRows = 0;
680
+ let unreadable = 0;
681
+ if (!(await tableExists(db, dialect, "approval_ask")))
682
+ return { askRows, unreadable };
683
+ const cardText = jsonColumnTextExpr(dialect, "card_json");
684
+ let after = "";
685
+ for (;;) {
686
+ const rows = await db.rows(dialect === "tidb"
687
+ ? `SELECT ask_id, ${jsonColumnText(dialect, "card_json")} FROM approval_ask WHERE (${cardText} LIKE ? OR ${cardText} LIKE ?) AND ask_id > ? ORDER BY ask_id LIMIT ${RETIRED_CARD_WORD_BATCH}`
688
+ : `SELECT ask_id, ${jsonColumnText(dialect, "card_json")} FROM approval_ask WHERE (${cardText} LIKE $1 OR ${cardText} LIKE $2) AND ask_id > $3 ORDER BY ask_id LIMIT ${RETIRED_CARD_WORD_BATCH}`, [...RETIRED_CARD_WORD_PATTERNS, after]);
689
+ for (const r of rows) {
690
+ const askId = String(r.ask_id);
691
+ after = askId;
692
+ const text = r.card_json;
693
+ if (typeof text !== "string")
694
+ continue;
695
+ const next = migrateRetiredCardWordsInCardJson(text);
696
+ if (next.kind === "unreadable") {
697
+ unreadable += 1;
698
+ continue;
699
+ }
700
+ if (next.kind === "untouched")
701
+ continue;
702
+ askRows += await db.exec(dialect === "tidb"
703
+ ? `UPDATE approval_ask SET card_json = ? WHERE ask_id = ? AND CAST(${cardText} AS BINARY) = CAST(? AS BINARY)`
704
+ : `UPDATE approval_ask SET card_json = $1 WHERE ask_id = $2 AND ${cardText} = $3`, [next.text, askId, text]);
705
+ }
706
+ if (rows.length < RETIRED_CARD_WORD_BATCH)
707
+ return { askRows, unreadable };
708
+ }
709
+ }
475
710
  const INCARNATION_BACKFILL_BATCH = 500;
476
711
  async function migrateSessionIncarnations(db, dialect) {
477
712
  if (!(await tableExists(db, dialect, "session_meta")))
@@ -530,7 +765,9 @@ export function logOneTimeMigrations(log, backend, report) {
530
765
  const changed = report.auditDetailJsonNullRows > 0 ||
531
766
  report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0 || report.allowRuleShadowRowsMigrated > 0 || report.allowRuleShadowRowsUnreadable > 0 ||
532
767
  report.workflowParkIndexKey === "reshaped" || report.undurableScopeRuleRows.length > 0 || report.undurableScopeRuleBucketsDeferred > 0 ||
533
- report.undurableScopeRuleBucketsUnreadable.length > 0;
768
+ report.undurableScopeRuleBucketsUnreadable.length > 0 ||
769
+ report.sessionRuleColumn === "renamed" || report.sessionRuleRowsWithRetiredBuckets.length > 0 || report.sessionRuleRowsWithOldGrammarEntries.length > 0 ||
770
+ report.retiredCardWordAskRowsRewritten > 0 || report.retiredCardWordRowsUnreadable > 0;
534
771
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
535
772
  backend,
536
773
  auditDetailJsonNullRows: report.auditDetailJsonNullRows,
@@ -544,6 +781,11 @@ export function logOneTimeMigrations(log, backend, report) {
544
781
  undurableScopeRuleRowsRemoved: report.undurableScopeRuleRows.length,
545
782
  undurableScopeRuleBucketsDeferred: report.undurableScopeRuleBucketsDeferred,
546
783
  undurableScopeRuleBucketsUnreadable: report.undurableScopeRuleBucketsUnreadable.length,
784
+ sessionRuleColumn: report.sessionRuleColumn,
785
+ sessionRuleRowsWithRetiredBuckets: report.sessionRuleRowsWithRetiredBuckets.length,
786
+ sessionRuleRowsWithOldGrammarEntries: report.sessionRuleRowsWithOldGrammarEntries.length,
787
+ retiredCardWordAskRowsRewritten: report.retiredCardWordAskRowsRewritten,
788
+ retiredCardWordRowsUnreadable: report.retiredCardWordRowsUnreadable,
547
789
  removableAfter: Object.fromEntries(ONE_TIME_MIGRATIONS.map((w) => [w.id, w.removableAfter])),
548
790
  note: "one-time persisted-data migrations (M4: audit details written as the JSON " +
549
791
  "text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly one on-disk spelling; M5: the " +
@@ -556,7 +798,16 @@ export function logOneTimeMigrations(log, backend, report) {
556
798
  "on a session written by an earlier version (which recorded no owner) claims that session for the principal, one session_owner_claimed line per claim; " +
557
799
  "M11: persisted permission rules whose project scope root is not a complete absolute path — accepted by earlier versions, dropped on every read " +
558
800
  "by the current engine and not removable through DELETE /v1/rules — removed, one permission_rule_row_removed line per row; a bucket the read path " +
559
- "refuses in whole is left untouched, one permission_rule_bucket_unreadable line per bucket). Each step " +
801
+ "refuses in whole is left untouched, one permission_rule_bucket_unreadable line per bucket; M17: the session-rules column renamed from `rules` to " +
802
+ "`rule_entries`, so a build that reads rules under the earlier name-only grammar finds no column to misread; at that rename every row's rule entries " +
803
+ "are read by the engine in the grammar the row carries (none = the grammar before the stamp) and rewritten as the same statement in the current grammar " +
804
+ "(the column carries the in-progress name rule_entries_old_grammar until every row is rewritten, so an interrupted boot resumes there) — toolAllow entries " +
805
+ "that the earlier grammar read as naming no tool are removed from the row, every row is stamped with the current grammar, one session_rule_row_old_grammar_entries " +
806
+ "line per row that lost entries; " +
807
+ "rows still holding a retired command bucket keep that bucket and are named, one session_rule_row_retired_bucket line per row on every boot; " +
808
+ "M18: stored approval cards written by an earlier engine with two words the current engine retired rewritten — ruleOffersAbsence shadowed removed, ask class " +
809
+ "governance_unavailable read as rule_unreadable; the engine's own parked-row documents are never rewritten (their row digest covers the ask class), they and " +
810
+ "the cards this step cannot reach are read the same way, counted under server.approval-card.retired-card-word-read). Each step " +
560
811
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
561
812
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
562
813
  "version that still carries a step at least once before upgrading past its deletion.",
@@ -582,6 +833,67 @@ export function logOneTimeMigrations(log, backend, report) {
582
833
  "keeps being refused, as before this release, until someone repairs or deletes that row by hand",
583
834
  });
584
835
  }
836
+ logSessionRuleOldGrammarRows(log, backend, report.sessionRuleRowsWithOldGrammarEntries);
837
+ for (const row of report.sessionRuleRowsWithRetiredBuckets) {
838
+ log.warn("session_rule_row_retired_bucket", {
839
+ backend,
840
+ policyKey: row.policyKey,
841
+ sessionId: row.sessionId,
842
+ buckets: row.buckets,
843
+ note: "a session's stored rules hold a retired command bucket (commandAllow / commandDeny), which the current rule grammar does not read: the bucket is left " +
844
+ "as written and every read of the row is refused as corrupt (the session's rules face answers 500 and its new tasks do not start). No rewrite is made for " +
845
+ "you: the retired bucket also refused every shell command line it could not read as one command name (a path such as /bin/rm, pipelines, &&), which no " +
846
+ "rule entry refuses. Rewrite the row in the database in the current grammar (toolDeny entries such as Bash(rm:*), or Bash to refuse the shell outright; " +
847
+ "keep rev and gen) or delete the session (deleting the session deletes its rules)",
848
+ });
849
+ }
850
+ }
851
+ function logSessionRuleOldGrammarRows(log, backend, rows) {
852
+ for (const row of rows) {
853
+ log.warn("session_rule_row_old_grammar_entries", {
854
+ backend,
855
+ policyKey: row.policyKey,
856
+ sessionId: row.sessionId,
857
+ toolAllowDropped: row.toolAllowDropped,
858
+ ...(row.wildcardMasked ? { wildcardMasked: true } : {}),
859
+ note: row.wildcardMasked
860
+ ? "a session's stored rules were written before the rule-grammar stamp and its toolAllow list holds `*`: the engine reads that list as void in every grammar (every call " +
861
+ "refused), which hides what the other entries meant in the earlier grammar, so this row was NOT rewritten or stamped — it stays in its earlier grammar. To restore the other " +
862
+ "entries, an operator rewrites the row without `*` (entries spelled as rules, `Bash(rm:*)`, then become allow rules — review them first)."
863
+ :
864
+ "a session's stored rules were written before the rule-grammar stamp, when an entry was compared as a tool name as written; the engine reads such a row " +
865
+ "in that earlier grammar, where these toolAllow entries named no tool and admitted nothing, while in the current grammar they would be allow rules. " +
866
+ "The row was rewritten as the same statement in the current grammar: these entries were removed and the row stamped (an emptied list stays in place and allows " +
867
+ "nothing); toolDeny entries read the same in every grammar and were left as written. Every entry named on this line is named exactly once, by the boot that " +
868
+ "removed it — no later boot lists it again — so keep the line. Write the session's rules afresh through the session policy endpoint if an entry was meant as an allow rule",
869
+ });
870
+ }
871
+ }
872
+ function logOneTimeMigrationsInterrupted(log, backend, error) {
873
+ const { receipts } = error;
874
+ log.warn("one_time_migrations_interrupted", {
875
+ backend,
876
+ sessionRuleColumn: receipts.sessionRuleColumn ?? "in-progress",
877
+ sessionRuleRowsWithOldGrammarEntries: receipts.sessionRuleRowsWithOldGrammarEntries.length,
878
+ note: "the one-time migrations failed after committing changes whose receipts the next boot cannot re-derive: the session_rule_row_old_grammar_entries lines that follow name " +
879
+ "rows this boot already rewrote — a retried boot finds them rewritten and removes nothing from them. These lines are the only place the rewritten rows are ever " +
880
+ "reported — no later boot lists them — so save them. The failure itself follows as this boot's refusal. sessionRuleColumn \"renamed\": every row was rewritten and the final " +
881
+ "column name was published before the failure; \"in-progress\": the column still carries its in-progress name — the next boot rewrites the remaining rows (rows already " +
882
+ "rewritten are left alone) and publishes the final name",
883
+ });
884
+ logSessionRuleOldGrammarRows(log, backend, receipts.sessionRuleRowsWithOldGrammarEntries);
885
+ }
886
+ export async function runAndLogOneTimeMigrations(log, backend) {
887
+ let report;
888
+ try {
889
+ report = await backend.runOneTimeMigrations();
890
+ }
891
+ catch (e) {
892
+ if (e instanceof OneTimeMigrationInterruptedError)
893
+ logOneTimeMigrationsInterrupted(log, backend.kind, e);
894
+ throw e;
895
+ }
896
+ logOneTimeMigrations(log, backend.kind, report);
585
897
  }
586
898
  function removedRuleRowNote(kind) {
587
899
  switch (kind) {
@@ -17,6 +17,7 @@ import { ensurePgPermissionRuleSchema } from "./permission-rule-store-sql.js";
17
17
  import { ensurePgLeaderRunSchema } from "./leader-run-store-sql.js";
18
18
  import { ensurePgRetentionSchema } from "./retention-store-sql.js";
19
19
  import { ensureIndexes } from "./ensure-index.js";
20
+ import { SESSION_RULES_COLUMN } from "./session-rules-column.js";
20
21
  import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
21
22
  export const PG_SCHEMA_STATEMENTS = [
22
23
  `CREATE TABLE IF NOT EXISTS task_run (
@@ -176,7 +177,8 @@ export const PG_SCHEMA_STATEMENTS = [
176
177
  -- sha256, so the principal is not recoverable from it. Enumeration-only — it is NOT part of the key and
177
178
  -- NOT part of the CAS / tighten-only path. NULL = a session-wide (principal-less) policy row.
178
179
  principal VARCHAR(190) COLLATE "C",
179
- rules JSONB NOT NULL,
180
+ -- 会话规则(TiDB 孪生同注:列定义唯一属主 = session-rules-column.ts 的 SESSION_RULES_COLUMN;7.128.x 及以前叫 rules,存量库由 M17 改名)。
181
+ ${SESSION_RULES_COLUMN.pg},
180
182
  rev BIGINT NOT NULL,
181
183
  -- gen (core 7.25.0 StoredSessionRules.gen, S-529 二期):TiDB 孪生同注 —— 这一行的血统,DEFAULT 0 = 第 0 代。
182
184
  gen BIGINT NOT NULL DEFAULT 0,
@@ -71,8 +71,9 @@ export interface RemoteContainerEnvConfig extends RemoteEnvObservers {
71
71
  * isRunning/files.*. A provider hang is surfaced as a typed retryable `RemoteExecutionError("timeout")` after
72
72
  * this, instead of pinning the worker for the sandbox lifetime. Default {@link DEFAULT_RPC_TIMEOUT_MS}. */
73
73
  rpcTimeoutMs?: number;
74
- /** 🔴 (hang class-fix) Idle/liveness bound (ms) for a command (exec/execStream): max time with NO output
75
- * and not yet complete = suspected hang. Also bounds the command-creation RPC. Default {@link DEFAULT_LIVENESS_MS}. */
74
+ /** 🔴 (hang class-fix) The lane's idle/liveness bound (ms; env `E2B_LIVENESS_MS`): max time with NO output and not yet
75
+ * complete = suspected hang — cuts a running command ONLY when its caller stated neither a wall-clock (`timeout`) nor its own
76
+ * idle bound (`readTimeoutMs`); the rule: `exec-bounds.ts` (creation included). Positive only (`laneLivenessOf`). Default `LIVENESS_MS_DEFAULT`. */
76
77
  livenessMs?: number;
77
78
  /** 🔴 Wall-clock for data-transfer/provisioning RPCs (large file io, snapshot suspend/resume,
78
79
  * create/connect) — wider than {@link rpcTimeoutMs} so a legit multi-minute transfer is not killed. Default
@@ -105,6 +106,8 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
105
106
  readonly cdPath: "unknown";
106
107
  private readonly cfg;
107
108
  private readonly sandboxApi;
109
+ /** This lane's own liveness bound — what {@link commandBoundsOf} falls back to when a command's caller stated no bound. */
110
+ private readonly laneLiveness;
108
111
  private sbx?;
109
112
  /** 🔴 BL-31 keep-alive — the E2B sandbox lifetime is hard-capped (≈1h, {@link E2B_SANDBOX_MAX_MS}), but a long
110
113
  * leader/repair run legitimately exceeds it. This interval RE-extends the sandbox (`setTimeout(timeoutMs)` resets
@@ -287,23 +290,42 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
287
290
  * the adapter NEVER blind-retries). A caller AbortSignal still aborts independently (op surfaces it / "aborted").
288
291
  */
289
292
  private rpc;
293
+ /**
294
+ * A command start that already returned to its caller (its creation deadline fired, or it failed): a handle the provider
295
+ * hands over LATER is killed — the caller was told the command was cut, so it must not run on. The rule for the in-flight
296
+ * count this start holds: it is released **once, and only when the command is confirmed not running** (council #4: never
297
+ * snapshot a running command). Confirmations: the provider answered `kill()` (`true` killed / `false` no such process);
298
+ * the handle's one `wait()` reports the process exited (a result, or `CommandExitError` for a non-zero exit). Not
299
+ * confirmations: a refused `kill()` (logged; the exit is still awaited), a `wait()` that ends without an exit (the
300
+ * connection deadline, a lost stream — the process may still run). With neither the count stays held and `suspendVM`
301
+ * keeps refusing `command_in_flight` — the same arm as a start that never settles. No start promise (the op never ran)
302
+ * ⇒ released now. A failed start is released at once too, as in 7.129.0 — and that one is NOT a confirmation: there is
303
+ * no handle to kill or wait on, and a start whose reply was lost (the SDK's own 60 s handshake) may have spawned the
304
+ * process. Closing it means classifying the start failure (provider refused ⇒ not started; lost reply ⇒ held) — open,
305
+ * not done here.
306
+ */
307
+ private settleAbandonedStart;
290
308
  /**
291
309
  * Per-command wall-clock for the E2B SDK (seconds→ms). 🔴 This is a PHYSICAL CAP, NOT the hang
292
310
  * detector. The old code returned `this.cfg.timeoutMs` (the sandbox lifetime) when no `timeout` was given AND
293
311
  * had no liveness check → a hung command waited the WHOLE lifetime (~30 min, the original hang bug). The fix is
294
312
  * NOT to shorten this cap (that would spuriously kill a long-but-progressing `npm ci`) — it's to add a LIVENESS
295
313
  * bound ({@link cfg.livenessMs}) in exec/execStream that catches a *zero-progress* command in ~livenessMs,
296
- * independent of this cap. So: explicit caller `timeout` wins; else the lifetime is the cap (a command can't
297
- * outlive its VM) but hang detection no longer depends on it.
314
+ * independent of this cap. So: explicit caller `timeout` wins — and is then the command's bound (the liveness bound
315
+ * stays out, `exec-bounds.ts`); else the lifetime is the cap (a command can't outlive its VM) and the liveness bound
316
+ * is the hang detector.
298
317
  */
299
318
  private commandTimeoutMs;
300
319
  /**
301
- * 🔴 Liveness for the buffered exec WAIT phase. `handle.wait()` is a long-poll with no read timeout —
320
+ * 🔴 Bounds for the buffered exec WAIT phase. `handle.wait()` is a long-poll with no read timeout —
302
321
  * a command that hangs DURING execution (not creation) would otherwise wait the E2B command timeout (= the
303
- * lifetime when none is set, the original hang bug). Races wait() against an idle check: if no stdout/stderr for
304
- * `livenessMs`, the command is hung → kill + typed retryable "timeout". `lastProgress()` is bumped by the
305
- * stdout/stderr callbacks, so a slow-but-PROGRESSING command (long `npm ci`) is never killed. (execStream has
306
- * its own chunk-driven idle timer; this is the buffered-exec analogue.)
322
+ * lifetime when none is set, the original hang bug). With a `liveness` bound (the caller stated no `timeout` —
323
+ * `exec-bounds.ts`) it races wait() against an idle check: no stdout/stderr for `liveness.ms` ⇒ hung → kill + typed
324
+ * retryable "timeout" with the shared cut sentence. `lastProgress()` is bumped by the stdout/stderr callbacks, so a
325
+ * slow-but-PROGRESSING command (long `npm ci`) is never killed. Both cuts reject with an `ExecutionError` carrying
326
+ * its real code (exec returns it as-is) — a `RemoteExecutionError` here would reach {@link execError}'s text match
327
+ * and land as `unknown`. The rejection is armed BEFORE the kill: the kill rejects `wait()` too, and the race must
328
+ * see the bound, not the kill. (execStream has its own chunk-driven idle timer; this is the buffered-exec analogue.)
307
329
  */
308
330
  private waitWithLiveness;
309
331
  /** Lazily provision the VM on first use: a task that never touches the hand pays zero cost.