@sema-agent/server 7.75.4 → 7.76.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.
@@ -0,0 +1,126 @@
1
+ import { join } from "node:path";
2
+ import { atomicWriteFile, ensureDir, readJsonlRecords, AppendLog } from "@sema-agent/core";
3
+ import { mysqlDriver, pgDriver, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
4
+ import { isDupKeyError } from "./sql-errors.js";
5
+ export const APPROVAL_NONCE_TABLE = "approval_nonce";
6
+ export const MAX_APPROVAL_NONCE_CHARS = 190;
7
+ export class SqlApprovalNonceStore {
8
+ db;
9
+ constructor(db) {
10
+ this.db = db;
11
+ }
12
+ q(tidb, pg) {
13
+ return this.db.dialect === "tidb" ? tidb : pg;
14
+ }
15
+ async nowMs() {
16
+ return sqlDbNowMs(this.db, APPROVAL_NONCE_TABLE);
17
+ }
18
+ async consume(sessionId, nonce, expiresAtMs) {
19
+ try {
20
+ await this.db.query(this.q(`INSERT INTO ${APPROVAL_NONCE_TABLE} (session_id, nonce, consumed_at_ms, expires_at_ms) VALUES (?,?,${SQL_DB_NOW_MS.tidb},?)`, `INSERT INTO ${APPROVAL_NONCE_TABLE} (session_id, nonce, consumed_at_ms, expires_at_ms) VALUES ($1,$2,${SQL_DB_NOW_MS.pg},$3)`), [sessionId, nonce, expiresAtMs]);
21
+ return true;
22
+ }
23
+ catch (err) {
24
+ if (isDupKeyError(this.db.dialect, err))
25
+ return false;
26
+ throw err;
27
+ }
28
+ }
29
+ async reapExpired() {
30
+ const res = await this.db.query(this.q(`DELETE FROM ${APPROVAL_NONCE_TABLE} WHERE expires_at_ms <= ${SQL_DB_NOW_MS.tidb}`, `DELETE FROM ${APPROVAL_NONCE_TABLE} WHERE expires_at_ms <= ${SQL_DB_NOW_MS.pg}`), []);
31
+ return res.affected;
32
+ }
33
+ }
34
+ export class TiDBApprovalNonceStore extends SqlApprovalNonceStore {
35
+ constructor(pool) {
36
+ super(mysqlDriver(pool));
37
+ }
38
+ }
39
+ export class PgApprovalNonceStore extends SqlApprovalNonceStore {
40
+ constructor(pool) {
41
+ super(pgDriver(pool));
42
+ }
43
+ }
44
+ const keyOf = (sessionId, nonce) => JSON.stringify([sessionId, nonce]);
45
+ export class MemoryApprovalNonceStore {
46
+ rows = new Map();
47
+ async nowMs() {
48
+ return Date.now();
49
+ }
50
+ async consume(sessionId, nonce, expiresAtMs) {
51
+ const k = keyOf(sessionId, nonce);
52
+ if (this.rows.has(k))
53
+ return false;
54
+ this.rows.set(k, expiresAtMs);
55
+ return true;
56
+ }
57
+ async reapExpired() {
58
+ const now = await this.nowMs();
59
+ let n = 0;
60
+ for (const [k, exp] of this.rows) {
61
+ if (exp <= now) {
62
+ this.rows.delete(k);
63
+ n++;
64
+ }
65
+ }
66
+ return n;
67
+ }
68
+ }
69
+ export class FileApprovalNonceStore {
70
+ path;
71
+ tmpDir;
72
+ rows = new Map();
73
+ log;
74
+ constructor(root) {
75
+ const dir = join(root, "approval-nonces");
76
+ ensureDir(dir);
77
+ this.tmpDir = join(dir, ".tmp");
78
+ ensureDir(this.tmpDir);
79
+ this.path = join(dir, "consumed.jsonl");
80
+ for (const r of readJsonlRecords(this.path)) {
81
+ if (!r || typeof r.s !== "string" || typeof r.n !== "string" || typeof r.e !== "number")
82
+ continue;
83
+ this.rows.set(keyOf(r.s, r.n), { s: r.s, n: r.n, e: r.e });
84
+ }
85
+ }
86
+ logFor() {
87
+ return (this.log ??= new AppendLog(this.path));
88
+ }
89
+ async nowMs() {
90
+ return Date.now();
91
+ }
92
+ async consume(sessionId, nonce, expiresAtMs) {
93
+ const k = keyOf(sessionId, nonce);
94
+ if (this.rows.has(k))
95
+ return false;
96
+ const record = { s: sessionId, n: nonce, e: expiresAtMs };
97
+ this.logFor().append(record, true);
98
+ this.rows.set(k, record);
99
+ return true;
100
+ }
101
+ async reapExpired() {
102
+ const nowMs = await this.nowMs();
103
+ const survivors = [];
104
+ let removed = 0;
105
+ for (const record of this.rows.values()) {
106
+ if (record.e <= nowMs) {
107
+ removed++;
108
+ continue;
109
+ }
110
+ survivors.push(record);
111
+ }
112
+ if (removed === 0)
113
+ return 0;
114
+ this.log?.closeForSwap();
115
+ atomicWriteFile(this.tmpDir, this.path, survivors.map((r) => `${JSON.stringify(r)}\n`).join(""));
116
+ for (const [k, record] of [...this.rows])
117
+ if (record.e <= nowMs)
118
+ this.rows.delete(k);
119
+ return removed;
120
+ }
121
+ dispose() {
122
+ this.log?.close();
123
+ this.log = undefined;
124
+ }
125
+ }
126
+ //# sourceMappingURL=approval-nonce-store.js.map
@@ -250,30 +250,6 @@ export type RuleTicketReclaimResult = {
250
250
  * 廉价,重铸一张即可(core [ref] 建议的姿态)。
251
251
  */
252
252
  export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
253
- /**
254
- * [ref] A7 —— **数据库自己的钟**(毫秒),两方言各一句(codex 交叉轮 R5-F1,验真后修)。
255
- *
256
- * 用处:`settled_at_ms` 的写入与清扫「已落定」那一支的截止**必须在同一个钟域**。它们此前各拿各副本的
257
- * 墙钟:落定那台若比跑清扫那台慢过一个宽限(本仓 [ref]③ 记过一次真实的 11 分钟 NTP 纠正),一份**刚刚**
258
- * 生出来的权威回执下一拍就被删,而"落定之后必有一个完整的回放窗"是本批新许下的承诺 —— 它不能建立在
259
- * 「两台机器的钟差不多」这条没人声明过的假设上。数据库的钟是这两台副本**唯一**共享的那口钟。
260
- *
261
- * 🔴 射程收官([ref] / [ref],2026-09-02):**这张票上的每一个时刻量都走本钟**,一个不剩 ——
262
- * `consumed_at_ms` / `settled_at_ms`(R8-F2 起)、`expires_at_ms` / `created_at_ms`(本批)。
263
- *
264
- * 本批补上的是 **TTL 轴**,也是最后一条:铸票 `expires_at_ms = <本钟> + ttl`、判定
265
- * `expires_at_ms > <本钟>`、清扫 `expires_at_ms <= <本钟> − 宽限`。修前那一形是**两台副本各出一半**的
266
- * 比较(铸票副本的墙钟定到期时刻,兑付副本的墙钟做判定),于是 TTL 在集群上**不单调**:同一张票在钟慢
267
- * 的那台上还活着、在钟快的那台上已经死了;极端形是「出生即过期」(铸票副本慢过一个 TTL ⇒ 票铸出来就
268
- * 没人兑得动)。这不需要故障就能发作 —— 负载均衡把 prepare 与 redeem 分到两台副本是**常态**。
269
- * 上一批的进程内单调锚只覆盖「同一副本铸、同一副本兑」那一格,连同它的台账在本批一并删除。
270
- *
271
- * 判定读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置(fail-closed:绝不退回本地墙钟放行)。
272
- */
273
- export declare const SQL_DB_NOW_MS: {
274
- readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
275
- readonly pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint";
276
- };
277
253
  /** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。
278
254
  *
279
255
  * 🔴 入参型收到 core 的 `RuleCandidate`(core 7.9.0 [ref] 起含 `behavior`)—— 规则的身份是
@@ -294,12 +270,7 @@ export declare class SqlRuleImportTicketStore {
294
270
  private readonly db;
295
271
  constructor(db: SqlDriver);
296
272
  private q;
297
- /**
298
- * 问一次**数据库自己的钟**(毫秒)。铸票用它当基准,于是到期时刻与后续每一次判定同域。
299
- *
300
- * 读不出数 ⇒ **响亮抛**([ref] fail-closed):静默兜个本地 `Date.now()` 正是本批要删掉的那条腿,
301
- * 而一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值。
302
- */
273
+ /** 问一次**数据库自己的钟**(毫秒,{@link sqlDbNowMs})。铸票用它当基准,于是到期时刻与后续每一次判定同域。 */
303
274
  private dbNowMs;
304
275
  /**
305
276
  * 铸一张票。
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
4
- import { dialectProtocolJsonEncoder } from "./sql-driver.js";
4
+ import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
7
  import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
@@ -624,10 +624,6 @@ export class SqlRuleApprovalRecordStore {
624
624
  }
625
625
  }
626
626
  export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
627
- export const SQL_DB_NOW_MS = {
628
- tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)",
629
- pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint",
630
- };
631
627
  export function buildRulePayloadHash(candidates) {
632
628
  return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
633
629
  }
@@ -640,11 +636,7 @@ export class SqlRuleImportTicketStore {
640
636
  return this.db.dialect === "tidb" ? tidbSql : pgSql;
641
637
  }
642
638
  async dbNowMs() {
643
- const { rows } = await this.db.query(this.q(`SELECT ${SQL_DB_NOW_MS.tidb} AS db_now_ms`, `SELECT ${SQL_DB_NOW_MS.pg} AS db_now_ms`), []);
644
- const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
645
- if (!Number.isFinite(n))
646
- throw new Error(`${PERMISSION_RULE_TICKET_TABLE}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
647
- return n;
639
+ return sqlDbNowMs(this.db, PERMISSION_RULE_TICKET_TABLE);
648
640
  }
649
641
  async mint(input) {
650
642
  const nowMs = await this.dbNowMs();
@@ -125,6 +125,14 @@ export const PG_SCHEMA_STATEMENTS = [
125
125
  created_at TIMESTAMPTZ(3) NOT NULL,
126
126
  PRIMARY KEY (session_id, tool_name)
127
127
  )`,
128
+ `CREATE TABLE IF NOT EXISTS approval_nonce (
129
+ session_id VARCHAR(64) COLLATE "C" NOT NULL,
130
+ nonce VARCHAR(190) COLLATE "C" NOT NULL,
131
+ consumed_at_ms BIGINT NOT NULL,
132
+ expires_at_ms BIGINT NOT NULL,
133
+ PRIMARY KEY (session_id, nonce)
134
+ )`,
135
+ `CREATE INDEX IF NOT EXISTS idx_approval_nonce_expires ON approval_nonce (expires_at_ms)`,
128
136
  `CREATE TABLE IF NOT EXISTS sendfile_link (
129
137
  id VARCHAR(64) COLLATE "C" NOT NULL,
130
138
  scope VARCHAR(190) COLLATE "C" NOT NULL,
@@ -49,6 +49,7 @@ export const PG_SESSION_SCHEMA = [
49
49
  PRIMARY KEY (session_id, seq)
50
50
  )`,
51
51
  `CREATE UNIQUE INDEX IF NOT EXISTS uq_session_event_entry ON session_event (session_id, entry_id)`,
52
+ `CREATE INDEX IF NOT EXISTS idx_session_event_type ON session_event (session_id, type, seq)`,
52
53
  ];
53
54
  export async function ensureSchema(pool) {
54
55
  for (const stmt of PG_SESSION_SCHEMA)
@@ -158,6 +158,33 @@ export interface SqlEngineFacts {
158
158
  /** TiDB's session `tidb_txn_mode`; `null` = this engine has no such indicator (NOT "optimistic"). */
159
159
  readonly txnMode: "pessimistic" | null;
160
160
  }
161
+ /**
162
+ * **数据库自己的钟**(毫秒),两方言各一句 —— 本仓这口钟在 SQL 面的**唯一**铸点([ref] A7 / [ref] /
163
+ * 合并复审 R2 把 S-280 的 nonce 店并进来)。
164
+ *
165
+ * 用处永远是同一条:**一个时刻量的铸造点与它的判定点必须在同一个钟域**。副本各拿各的墙钟时,两台机器
166
+ * 之间的偏差没有任何一条腿强制过上界(本仓 [ref]③ 记过一次真实的 11 分钟 NTP 纠正),于是
167
+ * 「A 铸的东西 B 判着还活着」这类承诺就建立在一条没人声明过的假设上。数据库的钟是一组副本**唯一**
168
+ * 共享的那口钟,所以每一条「谁生谁死」的判决都落到它身上:
169
+ * · 规则导入票(`permission-rule-store-sql.ts`):`expires_at_ms` 的铸造 / 判定 / 清扫三处;
170
+ * · 直连门 plan_review 的消费记录(`approval-nonce-store.ts`):受理窗的判定与记录的清扫。
171
+ *
172
+ * 读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置([ref] fail-closed:**绝不**退回本地墙钟放行)。
173
+ *
174
+ * 它住在 driver 这只叶子模块里,是因为「这个引擎怎么说『现在几点』」是**方言事实**,与任何一只店无关;
175
+ * 放在某一只店里会让下一个需要它的店要么 import 一整只无关的店、要么自己抄一份(抄出来的两份会漂)。
176
+ */
177
+ export declare const SQL_DB_NOW_MS: {
178
+ readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
179
+ readonly pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint";
180
+ };
181
+ /**
182
+ * 问一次 {@link SQL_DB_NOW_MS} —— 所有需要「库钟此刻」的店共用这**一份**读法。
183
+ *
184
+ * `subject` 只进错误文案(出问题时运维要知道是哪一张表的判决卡住了)。读不出数 ⇒ **响亮抛**:
185
+ * 一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值,更不是掉回 `Date.now()` 的理由。
186
+ */
187
+ export declare function sqlDbNowMs(db: SqlDriver, subject: string): Promise<number>;
161
188
  export interface SqlDriver extends SqlExec {
162
189
  readonly dialect: SqlDialect;
163
190
  /**
@@ -1,4 +1,16 @@
1
1
  import { pgSafeJsonStringify, pgProtocolJsonStringify } from "./pg-safe-json.js";
2
+ export const SQL_DB_NOW_MS = {
3
+ tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)",
4
+ pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint",
5
+ };
6
+ export async function sqlDbNowMs(db, subject) {
7
+ const sql = db.dialect === "tidb" ? `SELECT ${SQL_DB_NOW_MS.tidb} AS db_now_ms` : `SELECT ${SQL_DB_NOW_MS.pg} AS db_now_ms`;
8
+ const { rows } = await db.query(sql, []);
9
+ const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
10
+ if (!Number.isFinite(n))
11
+ throw new Error(`${subject}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
12
+ return n;
13
+ }
2
14
  function mysqlResult(r) {
3
15
  if (Array.isArray(r))
4
16
  return { rows: r, affected: 0 };
@@ -8,6 +8,7 @@ import { type OneTimeMigrationReport } from "./one-time-migrations.js";
8
8
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
9
9
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
10
10
  import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
11
+ import { type ApprovalNonceStore } from "./approval-nonce-store.js";
11
12
  import { type ApprovalAskStore as ApprovalAskStoreType } from "./approval-ask-store-sql.js";
12
13
  import { type MemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
13
14
  import { type SendFileLedger } from "./send-file-ledger.js";
@@ -39,6 +40,7 @@ export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
39
40
  * FileResumeAnchorStore is the FILE-backed local twin (P0.5 variant-1; survives restart). */
40
41
  export type ResumeAnchorStore = TiDBResumeAnchorStore | PgResumeAnchorStore | FileResumeAnchorStore;
41
42
  export type { ApprovalExemptionStore } from "./approval-exemption-store.js";
43
+ export type { ApprovalNonceStore } from "./approval-nonce-store.js";
42
44
  /** [ref]([ref] v3.1 §3.0-§3.2 流内审批协议持久层):`ApprovalAskStore` 接口 + 行/补丁类型。
43
45
  * 车3 刀 3a 起 `StoreBackend.approvalAsk()` 已在(见接口体的那条注)。 */
44
46
  export type { ApprovalAskStore, AskRow, NewAskRow, AskTransitionPatch, DecideAskInput, DecideResult, ExpireResult, BindGateInput, BatchRow, AskDecision } from "./approval-ask-store-sql.js";
@@ -147,6 +149,10 @@ export interface StoreBackend {
147
149
  /** Per-session per-toolName approval exemption ("本会话不再询问") — an approval MEMORY the F4
148
150
  * ask-gate consults after deny/neverAuto. REQUIRED on all backends (works local, like resumeAnchor). */
149
151
  approvalExemption(): ApprovalExemptionStore;
152
+ /** S-280 直连门审批证明的一次性消费记录(`plan_review` 的 nonce)。REQUIRED on all backends —— 与
153
+ * `approvalExemption` 同姿势:tidb/pg = SQL 双方言(单类双语句),local = File 追加日志 + 压实。
154
+ * **不能**退化成进程内易失形:一次重启若把记录抹掉,JWT TTL 窗内的重放就复活了,而那正是本店要关的洞。 */
155
+ approvalNonce(): ApprovalNonceStore;
150
156
  /** [ref]([ref] §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
151
157
  * REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
152
158
  *
@@ -12,6 +12,7 @@ import { LocalSessionStore } from "./local-session-store.js";
12
12
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
13
13
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
14
14
  import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
15
+ import { TiDBApprovalNonceStore, PgApprovalNonceStore, FileApprovalNonceStore } from "./approval-nonce-store.js";
15
16
  import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
16
17
  import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
17
18
  import { TiDBMemoryOptOutGrantStore, PgMemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
@@ -112,6 +113,7 @@ class TiDBBackend {
112
113
  run() { return new TiDBRunStore(this.pool); }
113
114
  resumeAnchor() { return new TiDBResumeAnchorStore(this.pool); }
114
115
  approvalExemption() { return new TiDBApprovalExemptionStore(this.pool); }
116
+ approvalNonce() { return new TiDBApprovalNonceStore(this.pool); }
115
117
  approvalAsk() { return new TiDBApprovalAskStore(this.pool); }
116
118
  permissionRule() { return createSqlPermissionRuleStores(mysqlDriver(this.pool)); }
117
119
  sendFileLedger() { return new TiDBSendFileLedger(this.pool); }
@@ -159,6 +161,7 @@ class PgBackend {
159
161
  run() { return new PgRunStore(this.pool); }
160
162
  resumeAnchor() { return new PgResumeAnchorStore(this.pool); }
161
163
  approvalExemption() { return new PgApprovalExemptionStore(this.pool); }
164
+ approvalNonce() { return new PgApprovalNonceStore(this.pool); }
162
165
  approvalAsk() { return new PgApprovalAskStore(this.pool); }
163
166
  permissionRule() { return createSqlPermissionRuleStores(pgDriver(this.pool)); }
164
167
  sendFileLedger() { return new PgSendFileLedger(this.pool); }
@@ -200,6 +203,7 @@ class LocalBackend {
200
203
  sessionStore;
201
204
  resumeAnchorStore;
202
205
  approvalExemptionStore;
206
+ approvalNonceStore;
203
207
  approvalAskStore = new InMemoryApprovalAskStore();
204
208
  sendFileLedgerStore;
205
209
  workflowJournalStore;
@@ -238,6 +242,7 @@ class LocalBackend {
238
242
  });
239
243
  this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
240
244
  this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
245
+ this.approvalNonceStore = new FileApprovalNonceStore(this.fileBackend.root);
241
246
  this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
242
247
  this.workflowJournalStore = new FileWorkflowJournalStore(this.fileBackend.root);
243
248
  this.outcomeSinkInst = new FileOutcomeSink(this.fileBackend.root);
@@ -255,6 +260,7 @@ class LocalBackend {
255
260
  this.runStore.dispose();
256
261
  this.resumeAnchorStore.dispose();
257
262
  this.approvalExemptionStore.dispose();
263
+ this.approvalNonceStore.dispose();
258
264
  this.sendFileLedgerStore.dispose();
259
265
  this.workflowJournalStore.dispose();
260
266
  this.checkpointStoreInst?.close();
@@ -264,6 +270,7 @@ class LocalBackend {
264
270
  run() { return this.runStore; }
265
271
  resumeAnchor() { return this.resumeAnchorStore; }
266
272
  approvalExemption() { return this.approvalExemptionStore; }
273
+ approvalNonce() { return this.approvalNonceStore; }
267
274
  approvalAsk() { return this.approvalAskStore; }
268
275
  permissionRule() {
269
276
  if (!this.permissionRuleStoresInst) {
@@ -69,7 +69,24 @@ export const SCHEMA_STATEMENTS = [
69
69
  payload JSON NOT NULL,
70
70
  ts DATETIME(3) NOT NULL,
71
71
  PRIMARY KEY (session_id, seq),
72
- UNIQUE KEY uq_session_event_entry (session_id, entry_id)
72
+ UNIQUE KEY uq_session_event_entry (session_id, entry_id),
73
+ -- S-284(7.76.0):「本会话按 seq 倒序的最新一条某 type 事件」这一**类**读法的访问路径。
74
+ -- 列序 = 等值前缀 (session_id, type) + 排序列 seq。
75
+ -- · **热路径(本索引的存在理由)**:computeFloor 的 type = 'compaction' ORDER BY seq DESC LIMIT 1 ——
76
+ -- 每次 wake 的第一条 SQL。缺这条路径时两库都退化成**整会话扫**:TiDB 按 PK 取整 session 区间
77
+ -- (keep order:false)把 N 行 ~1KB payload 全读进 cop 侧 TopN(实测 N=100 → 10000:0.91ms → 30.91ms);
78
+ -- PG 的规划器随表变大从 Index Scan Backward 翻成 Bitmap Heap Scan、再翻成 Seq Scan + Sort
79
+ -- (0.23ms → 1.42ms)。于是「每轮热读 = floor 之上那 ~30 行」这条 §1.5 承诺被第一条 SQL 单方面毁掉。
80
+ -- 补上之后两库都是一次索引定位 + 一行回表:TiDB IndexRangeScan keep order:true, desc + cop Limit 1
81
+ -- (1.04 / 0.90 / 0.87ms),PG Index Scan Backward(0.23 / 0.21 / 0.21ms)—— 与 N 无关。
82
+ -- · **冷路径(**只有 PG 实测吃到**,如实登记)**:getEpochAnchor 的
83
+ -- seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100。实测(N=10058、
84
+ -- 58 条候选):PG 走 Index Scan + 小 Sort,2.04ms → 0.49ms;**TiDB 在 pseudo 统计下不选这条索引**,
85
+ -- 计划仍是整会话 TableRangeScan keep order:true, desc + cop Selection —— 与无索引时同一个计划。
86
+ -- 不在本版加方言索引提示(那是一条特判,且该路径是 rewind 切在 kept tail 内才走的冷回退,不是每轮热读)。
87
+ -- 写路径代价(实测,非估计):多一条二级索引项 ⇒ TiDB 单行 append p50 1.34 → 1.58ms、10058 行批量装载
88
+ -- 845 → 952ms;PG 单行 append p50 0.82 → 0.80ms(噪声内)、批量装载 375 → 405ms。
89
+ KEY idx_session_event_type (session_id, type, seq)
73
90
  ) COLLATE utf8mb4_bin`,
74
91
  `CREATE TABLE IF NOT EXISTS task_run (
75
92
  -- ⚠️ 表级 COLLATE 的**非键列**影响(#192 A11,codex 复审 2026-08-08 round2 点名——先前"只有
@@ -298,6 +315,14 @@ export const SCHEMA_STATEMENTS = [
298
315
  granted_by VARCHAR(190) NULL,
299
316
  created_at DATETIME(3) NOT NULL,
300
317
  PRIMARY KEY (session_id, tool_name)
318
+ ) COLLATE utf8mb4_bin`,
319
+ `CREATE TABLE IF NOT EXISTS approval_nonce (
320
+ session_id VARCHAR(64) NOT NULL,
321
+ nonce VARCHAR(190) NOT NULL,
322
+ consumed_at_ms BIGINT NOT NULL,
323
+ expires_at_ms BIGINT NOT NULL,
324
+ PRIMARY KEY (session_id, nonce),
325
+ KEY idx_approval_nonce_expires (expires_at_ms)
301
326
  ) COLLATE utf8mb4_bin`,
302
327
  `CREATE TABLE IF NOT EXISTS sendfile_link (
303
328
  id VARCHAR(64) NOT NULL,
@@ -1,5 +1,9 @@
1
1
  import { BaseSessionStorage, type PromptEpochDescriptor, type SessionMetadata, type SessionTreeEntry, type SessionWriteOptions } from "@sema-agent/core";
2
2
  import type { Pool } from "mysql2/promise";
3
+ export declare const SESSION_EVENT_LATEST_BY_TYPE_SQL: {
4
+ readonly floor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1";
5
+ readonly epochAnchor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100";
6
+ };
3
7
  /**
4
8
  * TiDB-backed {@link BaseSessionStorage} — the durable L1 event log for one session.
5
9
  *
@@ -3,6 +3,11 @@ import { BaseSessionStorage, SessionError, leafIdAfterEntry, normalizePromptEpoc
3
3
  import { toIso } from "./sql-row-helpers.js";
4
4
  import { isMysqlDupKeyError } from "./sql-errors.js";
5
5
  import { PLACEMENT_SELECT_COLUMNS, placementFromRow } from "./session-placement.js";
6
+ const SESSION_EVENT_BY_TYPE_HINT = "/*+ USE_INDEX(session_event, idx_session_event_type) */";
7
+ export const SESSION_EVENT_LATEST_BY_TYPE_SQL = {
8
+ floor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1`,
9
+ epochAnchor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100`,
10
+ };
6
11
  const isDupKey = isMysqlDupKeyError;
7
12
  export class TiDBSessionStorage extends BaseSessionStorage {
8
13
  pool;
@@ -19,7 +24,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
19
24
  return undefined;
20
25
  let cursor = this.floorSeq;
21
26
  for (;;) {
22
- const [rows] = await this.pool.query("SELECT seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100", [this.sessionId, cursor]);
27
+ const [rows] = await this.pool.query(SESSION_EVENT_LATEST_BY_TYPE_SQL.epochAnchor, [this.sessionId, cursor]);
23
28
  if (rows.length === 0)
24
29
  return undefined;
25
30
  for (const r of rows) {
@@ -44,7 +49,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
44
49
  return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, floorEntryId, floorSeq);
45
50
  }
46
51
  static async computeFloor(pool, sessionId) {
47
- const [compRows] = await pool.query("SELECT payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1", [sessionId]);
52
+ const [compRows] = await pool.query(SESSION_EVENT_LATEST_BY_TYPE_SQL.floor, [sessionId]);
48
53
  if (!compRows[0])
49
54
  return { floorEntryId: null, floorSeq: 0 };
50
55
  const comp = (typeof compRows[0].payload === "string" ? JSON.parse(compRows[0].payload) : compRows[0].payload);
@@ -1,4 +1,15 @@
1
1
  import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
2
+ /**
3
+ * 验签允许的**时钟不一致容差**(秒)—— 本仓这条轴上的单一来源。
4
+ *
5
+ * 它回答的是**一跳**:签发方(issuer)与判定这份 token 的那口钟可以差多少(exp/nbf 门的宽限)。
6
+ *
7
+ * 🔴 它**不**回答「两台副本之间差多少」:S-280 的消费记录不再靠任何钟差上界活着 —— 受理窗的判定、
8
+ * 落地后的复核、到期清扫三处读的是**同一口钟**(消费记录店自己的钟,见
9
+ * `plugins/approval-nonce-store.ts` 的 `nowMs()`)。曾经由本常量推导出的那个「副本间钟差」常量已
10
+ * 整条删除:它是一条**猜出来的**上界,没有任何一条腿强制它,而正确的修法是根本不需要这个数。
11
+ */
12
+ export declare const PRINCIPAL_JWT_CLOCK_SKEW_SEC = 30;
2
13
  /**
3
14
  * [ref] D-G: verify a compact JWS principal token. Returns the crypto-verified principal (`sub`) + the
4
15
  * channel-binding (`bnd`) on success, or `null` on ANY failure (caller fail-closes — NEVER falls back to a header).
@@ -14,15 +25,62 @@ export declare function verifyPrincipalJwt(token: string, keys: PrincipalJwtKey[
14
25
  }): {
15
26
  sub: string;
16
27
  bnd?: string;
28
+ acceptedBeforeMs: number;
17
29
  } | null;
18
30
  /** The channel-binding value a direct client's principal JWT must carry in `cnf.bnd`: the hex SHA-256 of the
19
31
  * CLIENT-VISIBLE action identity (sessionId + boundCallId + boundInputHash — NOT the unexposed checkpointToken).
20
32
  * Binds the identity assertion to THIS specific suspended action so a captured JWT cannot be lifted onto another. */
33
+ /**
34
+ * 直连门信封**第三槽**的 plan_review 半场:**决定所作用内容的哈希**。
35
+ *
36
+ * 第三槽在两种门上是**同一条规则**「这份决定作用在哪份内容上」:动作门填被批工具调用的 `boundInputHash`
37
+ * (core 铸);非动作门 `plan_review` 的 `edit` 填**修订后的计划**的哈希(计划随本次请求一起提交,签名器手上
38
+ * 就有原文),`approve` / `reject` 不作用于任何新内容 ⇒ null。B-121(test 黑盒发现,验真后修):此前 edit 也填
39
+ * null,于是为「只改 README」签出的证明可以拿去提交任意 `editedPlan`——签名只护住了 decision/reason,没护住
40
+ * 决定真正落地的那份内容。`cnf.bnd` 与 HMAC 都吃第三槽,所以 JWT 与信封同时绑住它;不匹配 ⇒
41
+ * `401 principal_unbound`(既有码,cnf.bnd 不符)。旧注记「计划哈希被拒」说的是 approve(worker 没有可哈希的
42
+ * 原文),不覆盖 edit。
43
+ *
44
+ * 形:`sha256(utf8(editedPlan))` hex —— 与 `approvalBnd` 同摘要、同编码;逐字节,不归一化(签名器与 worker
45
+ * 必须对同一串字节达成一致,任何"归一化"都是第二个写者)。
46
+ */
47
+ /** 良构 Unicode(无孤立代理项)。`u` 旗下配对代理项是一个码点、不匹配 `\p{Surrogate}`;只有孤立的才命中。 */
48
+ export declare function isWellFormedUnicode(s: string): boolean;
49
+ export declare function editedPlanBinding(editedPlan: string): string;
21
50
  export declare function approvalBnd(env: {
22
51
  sessionId: string;
23
52
  boundCallId?: string | null;
24
53
  boundInputHash?: string | null;
25
54
  }): string;
55
+ /**
56
+ * 一次性凭据:这份证明**必须被消费掉一次**才算数,以及它的消费记录该活到什么时候。
57
+ * 只有非动作门(plan_review)的证明带它 —— 动作门的一次性由 checkpointToken 给(见 {@link verifyDirectDoorProof})。
58
+ */
59
+ export interface SingleUseProof {
60
+ /** 签名器为这一次决定新铸的随机值(= 信封第 2 位 `boundCallId`,同时进 `cnf.bnd` 与 HMAC)。 */
61
+ readonly nonce: string;
62
+ /**
63
+ * 这份证明**不再被受理**的时刻(epoch ms,**开区间上界**:`店钟 >= 它` ⇒ 验签已经不收它)。
64
+ * 一个值、两处用,所以它们不可能漂:
65
+ * · 消费记录的到期列逐字落它 —— 清理腿的删除线就是**店钟**,没有任何余量、也不需要;
66
+ * · 消费**落地之后**的复核线(再问一次店钟)—— 消费是一次跨网络的写,验签发生在它之前。
67
+ *
68
+ * 🔴 「它」与「店钟」必须是同一口钟:本值由 `verifyPrincipalJwt` 从 `exp + skew` 算出,而那一关的
69
+ * `nowSec` 由调用方从**店钟**喂进来(`routes/approvals-assistant.ts`)。
70
+ */
71
+ readonly acceptedBeforeMs: number;
72
+ }
73
+ /** {@link verifyDirectDoorProof} 的判决。`singleUse` 在场 = 调用方必须在改任何状态**之前**消费它一次。 */
74
+ export type DirectDoorProofResult = {
75
+ ok: true;
76
+ principal: string;
77
+ singleUse?: SingleUseProof;
78
+ } | {
79
+ ok: false;
80
+ status: number;
81
+ errorCode: string;
82
+ error: string;
83
+ };
26
84
  /**
27
85
  * [ref] D-G: verify the WHOLE direct-door proof for one /decide — the signed principal JWT (identity) + its
28
86
  * cnf.bnd channel-binding + the HMAC envelope (integrity) — and return the CRYPTO-VERIFIED principal, or a typed
@@ -30,13 +88,22 @@ export declare function approvalBnd(env: {
30
88
  * verify principal → bind → mac-present → mac-valid (so a non-operator never gets a MAC oracle, an unverified
31
89
  * caller never reaches the operator list).
32
90
  *
91
+ * 🔴 `opts.nowSec`(S-280 / 合并复审 R2):受理窗要按**哪口钟**判。非动作门(plan_review)的反重放是
92
+ * 一条**消费记录**,而那条记录的生死由**消费记录店的钟**决定 —— 所以那条腿必须把店钟喂进来
93
+ * (`floor(店钟/1000)`),否则「还受理 ⇒ 记录还在」就退化成「两台副本的钟差不多」这条没人声明过的假设。
94
+ * 缺席 = 本机墙钟,这对动作门(`/decide`)是对的:它的反重放是 checkpointToken 的一次性,不牵扯第二台
95
+ * 机器上的一张行。
96
+ *
33
97
  * 🔴 `opts.actionBinding`: the boundCallId/boundInputHash action-binding is `policy_ask`-
34
98
  * SPECIFIC (a pending TOOL call). A NON-ACTION gate — `plan_review` (and any review-pause) — `binds NO action`
35
99
  * (core checkpoint-store.ts: pendingAction `{kind:"plan_review"}`, editedPlan is a typed sibling that binds no
36
100
  * action), so the proof correctly DEGRADES to "verify WHO": JWT identity + cnf.bnd channel-binding + the HMAC over
37
- * {sessionId, decision} (bound fields null) — WITHOUT requiring a boundInputHash. Pass `actionBinding:false` for a
38
- * non-action gate; the default (true) keeps the /decide tool-approval contract byte-identical. Binding the decision
39
- * to a plan HASH was REJECTED (the plan artifact lives in a profile ref store; the worker has no TOCTOU original).
101
+ * {sessionId, nonce, <content hash>, decision, reason}. Pass `actionBinding:false` for a non-action gate; the default
102
+ * (true) keeps the /decide tool-approval contract byte-identical. The THIRD slot carries one rule on both gates —
103
+ * "the content this decision acts on": the bound tool input for /decide, `editedPlanBinding(editedPlan)` for a
104
+ * plan_review `edit` (B-121), null for approve/reject. Binding approve/reject to a hash of the EXISTING plan was
105
+ * rejected (the plan artifact lives in a profile ref store; the worker has no TOCTOU original) — that ruling never
106
+ * covered `edit`, whose content arrives with the request.
40
107
  */
41
108
  export declare function verifyDirectDoorProof(proof: {
42
109
  jwt?: string;
@@ -56,13 +123,6 @@ export declare function verifyDirectDoorProof(proof: {
56
123
  approvalHmacKeys: ApprovalHmacKey[];
57
124
  }, opts?: {
58
125
  actionBinding?: boolean;
59
- }): {
60
- ok: true;
61
- principal: string;
62
- } | {
63
- ok: false;
64
- status: number;
65
- errorCode: string;
66
- error: string;
67
- };
126
+ nowSec?: number;
127
+ }): DirectDoorProofResult;
68
128
  //# sourceMappingURL=principal-jwt.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
2
2
  import { verifyApprovalHmac } from "./approval-hmac.js";
3
+ export const PRINCIPAL_JWT_CLOCK_SKEW_SEC = 30;
3
4
  function b64urlToBuf(s) {
4
5
  return Buffer.from(s, "base64url");
5
6
  }
@@ -61,15 +62,23 @@ export function verifyPrincipalJwt(token, keys, expect) {
61
62
  if (typeof payload.sub !== "string" || payload.sub.length === 0)
62
63
  return null;
63
64
  const now = expect.nowSec ?? Math.floor(Date.now() / 1000);
64
- const skew = expect.skewSec ?? 30;
65
- if (typeof payload.exp !== "number" || payload.exp + skew < now)
65
+ const skew = expect.skewSec ?? PRINCIPAL_JWT_CLOCK_SKEW_SEC;
66
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp + skew < now)
66
67
  return null;
67
- if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || payload.nbf - skew > now))
68
+ if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || !Number.isFinite(payload.nbf) || payload.nbf - skew > now))
68
69
  return null;
69
70
  if (expect.maxTtlSec && payload.exp - now > expect.maxTtlSec + skew)
70
71
  return null;
71
72
  const bnd = typeof payload.cnf?.bnd === "string" ? payload.cnf.bnd : undefined;
72
- return { sub: payload.sub, bnd };
73
+ return { sub: payload.sub, bnd, acceptedBeforeMs: (Math.floor(payload.exp + skew) + 1) * 1000 };
74
+ }
75
+ export function isWellFormedUnicode(s) {
76
+ return !/\p{Surrogate}/u.test(s);
77
+ }
78
+ export function editedPlanBinding(editedPlan) {
79
+ if (!isWellFormedUnicode(editedPlan))
80
+ throw new Error("editedPlanBinding: editedPlan is not well-formed Unicode (lone surrogate) — utf8 hashing would be lossy");
81
+ return createHash("sha256").update(editedPlan, "utf8").digest("hex");
73
82
  }
74
83
  export function approvalBnd(env) {
75
84
  return createHash("sha256").update(JSON.stringify([env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null])).digest("hex");
@@ -78,11 +87,16 @@ export function verifyDirectDoorProof(proof, envelope, config, opts) {
78
87
  const actionBinding = opts?.actionBinding ?? true;
79
88
  if (!proof.jwt)
80
89
  return { ok: false, status: 401, errorCode: "principal_unverified", error: "direct door requires a signed principal token (X-Approval-Principal-Token)" };
81
- const verified = verifyPrincipalJwt(proof.jwt, config.principalJwtPubkeys, { iss: config.principalJwtIss ?? "\0", aud: config.principalJwtAud ?? "\0", maxTtlSec: config.principalJwtMaxTtlSec });
90
+ const verified = verifyPrincipalJwt(proof.jwt, config.principalJwtPubkeys, { iss: config.principalJwtIss ?? "\0", aud: config.principalJwtAud ?? "\0", maxTtlSec: config.principalJwtMaxTtlSec, nowSec: opts?.nowSec });
82
91
  if (!verified)
83
92
  return { ok: false, status: 401, errorCode: "principal_unverified", error: "principal token failed verification" };
84
- if (actionBinding && !envelope.boundInputHash)
85
- return { ok: false, status: 400, errorCode: "approval_mac_required", error: "direct door requires boundInputHash (the bound action) in the body" };
93
+ if (actionBinding) {
94
+ if (!envelope.boundInputHash)
95
+ return { ok: false, status: 400, errorCode: "approval_mac_required", error: "direct door requires boundInputHash (the bound action) in the body" };
96
+ }
97
+ else if (!envelope.boundCallId) {
98
+ return { ok: false, status: 400, errorCode: "approval_nonce_required", error: "direct door requires a single-use nonce in boundCallId for a non-action gate (sign it into cnf.bnd and the HMAC envelope; see ASSISTANT-WIRE-CONTRACT §6.1/§6.2)" };
99
+ }
86
100
  if (verified.bnd !== approvalBnd(envelope))
87
101
  return { ok: false, status: 401, errorCode: "principal_unbound", error: "principal token is not bound to this decision (cnf.bnd mismatch)" };
88
102
  if (!proof.mac)
@@ -90,6 +104,8 @@ export function verifyDirectDoorProof(proof, envelope, config, opts) {
90
104
  const ok = verifyApprovalHmac({ sessionId: envelope.sessionId, boundCallId: envelope.boundCallId ?? null, boundInputHash: envelope.boundInputHash ?? null, decision: envelope.decision, reason: envelope.reason ?? null }, proof.mac, proof.kid, config.approvalHmacKeys);
91
105
  if (!ok)
92
106
  return { ok: false, status: 401, errorCode: "approval_mac_invalid", error: "approval envelope MAC invalid" };
93
- return { ok: true, principal: verified.sub };
107
+ return actionBinding
108
+ ? { ok: true, principal: verified.sub }
109
+ : { ok: true, principal: verified.sub, singleUse: { nonce: envelope.boundCallId, acceptedBeforeMs: verified.acceptedBeforeMs } };
94
110
  }
95
111
  //# sourceMappingURL=principal-jwt.js.map
@@ -468,7 +468,8 @@ export type RuleRefusalReason = Extract<CardRulePersisted, {
468
468
  * 部署整条车道判死。 */
469
469
  | "rule_governance_forced"
470
470
  /**
471
- * [ref]:**这一行**没有可铸规则的素材 —— durable(PARKED)迟到决议腿专属。
471
+ * [ref]:**这一行**没有可铸规则的素材。原为 durable(PARKED)迟到决议腿专属;自 live 腿也接上素材闸起**两腿同词**
472
+ * (第四条成因:命令含脱敏器认得的凭据形 ⇒ 素材整只扣下,两腿同谓词)。
472
473
  *
473
474
  * 成因闭集(三条,处置相同):①这只 ask 从没进过规则车道(治理档 / 无属主 / 引擎没铸候选 / args 里
474
475
  * 读不出命令原字节)⇒ `rule_command` 列 NULL;②行由**加这两列之前**的构建落下;③行上的卡读不回来