@sema-agent/server 7.11.0 → 7.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/USAGE.md +86 -2
- package/dist/adoption/plan.d.ts +38 -4
- package/dist/adoption/plan.js +72 -0
- package/dist/adoption/quiesce.d.ts +70 -0
- package/dist/adoption/quiesce.js +148 -0
- package/dist/adoption/runner.js +63 -5
- package/dist/adoption/sql.d.ts +15 -0
- package/dist/adoption/sql.js +18 -0
- package/dist/adoption/wire.d.ts +7 -1
- package/dist/adoption/wire.js +6 -0
- package/dist/approval-card.d.ts +5 -0
- package/dist/approval-card.js +22 -0
- package/dist/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/parked-revive-gate.d.ts +18 -2
- package/dist/boot/parked-revive-gate.js +136 -14
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +85 -0
- package/dist/boot/reapers.d.ts +15 -0
- package/dist/boot/reapers.js +101 -44
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/boot/runner-deps.d.ts +16 -2
- package/dist/boot/runner-deps.js +5 -4
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +31 -11
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -79
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/active-run-conflict.d.ts +33 -8
- package/dist/http/active-run-conflict.js +37 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/adoption.js +25 -2
- package/dist/http/routes/approvals-assistant.js +35 -4
- package/dist/http/routes/capabilities.js +69 -10
- package/dist/http/routes/images.js +18 -0
- package/dist/http/routes/rules.d.ts +19 -7
- package/dist/http/routes/rules.js +180 -4
- package/dist/http/routes/runs.js +21 -5
- package/dist/http/routes/tasks.js +18 -6
- package/dist/http/server.d.ts +30 -10
- package/dist/http/server.js +183 -19
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +65 -7
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/adoption-log-sql.d.ts +40 -0
- package/dist/plugins/adoption-log-sql.js +69 -2
- package/dist/plugins/file-run-store.d.ts +85 -1
- package/dist/plugins/file-run-store.js +450 -17
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
- package/dist/plugins/permission-rule-store-sql.js +71 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
- package/dist/plugins/shared-memory-store-sql.js +55 -18
- package/dist/plugins/sql-driver.d.ts +19 -0
- package/dist/plugins/sql-driver.js +12 -0
- package/dist/plugins/store-backend.d.ts +12 -6
- package/dist/plugins/store-backend.js +82 -10
- package/dist/rules-consent.d.ts +98 -1
- package/dist/rules-consent.js +84 -1
- package/dist/run-local.js +126 -15
- package/dist/runtime-governance.d.ts +33 -0
- package/dist/runtime-governance.js +41 -3
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +38 -1
- package/dist/tool-approval.js +125 -26
- package/dist/trace/core-keyset-guard.d.ts +14 -3
- package/dist/trace/project.d.ts +19 -2
- package/dist/trace/project.js +24 -4
- package/package.json +3 -3
|
@@ -215,17 +215,20 @@ export class SqlSharedMemoryStore {
|
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
|
-
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
218
|
+
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。
|
|
219
|
+
*
|
|
220
|
+
* 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
|
|
221
|
+
* 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
|
|
222
|
+
* 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
|
|
223
|
+
* MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
|
|
224
|
+
* 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
|
|
225
|
+
* 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
|
|
226
|
+
* MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
|
|
222
227
|
*/
|
|
223
228
|
async readBinding(scopes) {
|
|
224
229
|
const conn = await this.db.connect();
|
|
225
230
|
try {
|
|
226
|
-
await conn.
|
|
227
|
-
if (this.db.dialect === "pg")
|
|
228
|
-
await conn.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
231
|
+
await conn.beginRepeatableRead();
|
|
229
232
|
const scopeRows = await conn.query(this.q(`SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})`, `SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")})`), [...scopes]);
|
|
230
233
|
const storeRows = await conn.query(this.q(`SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")}) ORDER BY store_id`, `SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")}) ORDER BY store_id`), [...scopes]);
|
|
231
234
|
await conn.commit();
|
|
@@ -444,23 +447,57 @@ export class SqlSharedMemoryStore {
|
|
|
444
447
|
throw new Error(`shared-memory store ${JSON.stringify(record.storeId)} is not registered under ${JSON.stringify(record.scopeKey)} — either it was never created, or the id has since been recycled by another org (call putStore() first; a stale retry must NOT land in the new tenant's library)`);
|
|
445
448
|
}
|
|
446
449
|
}
|
|
447
|
-
/**
|
|
448
|
-
*
|
|
450
|
+
/**
|
|
451
|
+
* Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
|
|
452
|
+
* successor tenant's library). Returns whether a row was actually there.
|
|
453
|
+
*
|
|
454
|
+
* 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
|
|
455
|
+
* 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
|
|
456
|
+
* (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
|
|
457
|
+
* 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
|
|
458
|
+
* 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
|
|
459
|
+
* 拿到锁之后再删。
|
|
460
|
+
*
|
|
461
|
+
* 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
|
|
462
|
+
* 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
|
|
463
|
+
*/
|
|
449
464
|
async deleteDocument(storeId, scopeKey, path) {
|
|
450
|
-
await this.assertOwnedBy(storeId, scopeKey);
|
|
451
|
-
const res = await this.db.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
|
|
452
|
-
return res.affected > 0;
|
|
453
|
-
}
|
|
454
|
-
/** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
|
|
455
|
-
* 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
|
|
456
|
-
async assertOwnedBy(storeId, scopeKey) {
|
|
457
465
|
assertStoreId(storeId);
|
|
458
466
|
assertScopeKey(scopeKey);
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
let unregistered = false;
|
|
468
|
+
let deleted = false;
|
|
469
|
+
const conn = await this.db.connect();
|
|
470
|
+
try {
|
|
471
|
+
await conn.begin(); // 为什么不是 beginPessimistic:见本类头注的"事务与锁"段(与 putDocument 同判)
|
|
472
|
+
const registered = await conn.query(this.q(`SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = ? AND scope_key = ? FOR UPDATE`, `SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = $1 AND scope_key = $2 FOR UPDATE`), [storeId, scopeKey]);
|
|
473
|
+
if (registered.rows.length === 0) {
|
|
474
|
+
unregistered = true;
|
|
475
|
+
await conn.rollback();
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
const res = await conn.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
|
|
479
|
+
deleted = res.affected > 0;
|
|
480
|
+
await conn.commit();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
await rollbackPreservingError(conn, err);
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
conn.release();
|
|
489
|
+
}
|
|
490
|
+
if (unregistered) {
|
|
491
|
+
// 文案逐字同 assertOwnedBy(调用方/测试按它对表);围栏的**执行面**改了,拒绝的**说法**没改。
|
|
461
492
|
throw new Error(`shared-memory store ${JSON.stringify(storeId)} is not registered under ${JSON.stringify(scopeKey)} — either it does not exist, or the id has since been recycled by another org`);
|
|
462
493
|
}
|
|
494
|
+
return deleted;
|
|
463
495
|
}
|
|
496
|
+
// 属主围栏的共用断言 `assertOwnedBy()` 曾住在这里(轮3 F1)。A-010.14 把它**唯一**的消费者
|
|
497
|
+
// (`deleteDocument`)改成「同事务 + FOR UPDATE」之后它就没有调用点了 —— 留着一个自带围栏语义却
|
|
498
|
+
// 无人调用的私有方法,下一个人会以为「围栏在这儿,照着调就行」,而它恰恰是被判定为不够强的那一版。
|
|
499
|
+
// 边界的如实说明随之搬进 `deleteDocument` / `putDocument`:两者**都不**覆盖同一个 org 内的
|
|
500
|
+
// "删了又建"代际重用 —— 那不是跨租户面,真要挡住需要一枚不可变的 generation token。
|
|
464
501
|
/**
|
|
465
502
|
* De-register a store AND its documents, ATOMICALLY.
|
|
466
503
|
*
|
|
@@ -91,6 +91,25 @@ export interface SqlTxConn extends SqlExec {
|
|
|
91
91
|
begin(): Promise<void>;
|
|
92
92
|
/** TiDB: `BEGIN PESSIMISTIC` (current-read txn). PG: plain `BEGIN`. */
|
|
93
93
|
beginPessimistic(): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Open a transaction whose reads are pinned to ONE snapshot (A-010.11).
|
|
96
|
+
*
|
|
97
|
+
* 🔴 Why this is a named verb and not "just `begin()` — MySQL defaults to REPEATABLE READ anyway":
|
|
98
|
+
* a *server default* is not a structural guarantee. `transaction_isolation` is a settable
|
|
99
|
+
* server/session variable; a deployment (or a proxy, or a managed-MySQL vendor default) that runs
|
|
100
|
+
* READ COMMITTED silently turns "one transaction one snapshot" into "each statement its own
|
|
101
|
+
* snapshot" — and the caller that relied on it (`shared-memory-store-sql.ts` readBinding) goes on
|
|
102
|
+
* reading a torn scope/store pair with no error anywhere. The PG arm was already explicit; the
|
|
103
|
+
* MySQL arm was leaning on the default. Both arms now PIN it.
|
|
104
|
+
*
|
|
105
|
+
* The two arms diverge in WHERE the statement goes, and that is not cosmetic:
|
|
106
|
+
* · MySQL/TiDB — `SET TRANSACTION ISOLATION LEVEL …` with no scope keyword applies to the **next**
|
|
107
|
+
* transaction, and issuing it *inside* an open transaction is an ERROR
|
|
108
|
+
* (`ER_CANT_CHANGE_TX_CHARACTERISTICS`). So it must precede `beginTransaction()`.
|
|
109
|
+
* · PG — the same statement must be issued *inside* the transaction, before its first query.
|
|
110
|
+
* Writing one "portable" form would be wrong on one of the two engines; hence one verb, two texts.
|
|
111
|
+
*/
|
|
112
|
+
beginRepeatableRead(): Promise<void>;
|
|
94
113
|
commit(): Promise<void>;
|
|
95
114
|
rollback(): Promise<void>;
|
|
96
115
|
release(): void;
|
|
@@ -24,6 +24,12 @@ export function mysqlDriver(pool) {
|
|
|
24
24
|
beginPessimistic: async () => {
|
|
25
25
|
await c.query("BEGIN PESSIMISTIC");
|
|
26
26
|
},
|
|
27
|
+
// Scope-less `SET TRANSACTION …` = next-transaction only, so it goes BEFORE the BEGIN and
|
|
28
|
+
// leaves the pooled connection's session default untouched for whoever gets it next.
|
|
29
|
+
beginRepeatableRead: async () => {
|
|
30
|
+
await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
31
|
+
await c.beginTransaction();
|
|
32
|
+
},
|
|
27
33
|
commit: () => c.commit(),
|
|
28
34
|
rollback: () => c.rollback(),
|
|
29
35
|
release: () => c.release(),
|
|
@@ -50,6 +56,12 @@ export function pgDriver(pool) {
|
|
|
50
56
|
beginPessimistic: async () => {
|
|
51
57
|
await c.query("BEGIN");
|
|
52
58
|
},
|
|
59
|
+
// PG takes it INSIDE the transaction (and only before its first query) — the mirror image of
|
|
60
|
+
// the MySQL arm above. Same verb, deliberately different placement.
|
|
61
|
+
beginRepeatableRead: async () => {
|
|
62
|
+
await c.query("BEGIN");
|
|
63
|
+
await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
64
|
+
},
|
|
53
65
|
commit: async () => {
|
|
54
66
|
await c.query("COMMIT");
|
|
55
67
|
},
|
|
@@ -24,7 +24,7 @@ import type { CounterDegradeHook } from "./write-behind-counter.js";
|
|
|
24
24
|
import { PgRateLimiter } from "./pg-rate-limiter.js";
|
|
25
25
|
import { type SqlSharedMemoryStore, type SharedMemoryScopeAuthorizer } from "./shared-memory-store-sql.js";
|
|
26
26
|
import { type AdoptionLogStore } from "./adoption-log-sql.js";
|
|
27
|
-
import {
|
|
27
|
+
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
28
28
|
export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
|
|
29
29
|
/** E18 resume-at eventId→entryId anchor map — tidb/pg/local 3-backend (works LOCAL; needs only the session tree +
|
|
30
30
|
* this map, no cloud-only checkpoint). Union = the three nominal twins; consumers use put/resolve/deleteBySession.
|
|
@@ -138,10 +138,12 @@ export interface StoreBackend {
|
|
|
138
138
|
*
|
|
139
139
|
* `local` 车道**刻意不给 in-memory twin**:一条规则是「人授权过的持久事实」,进程内 Map 形会在重启时
|
|
140
140
|
* 静默丢掉那份授权,而消费端(下一次同命令的 ask)读到的是「没有规则」——那是**放宽面**上的静默降级。
|
|
141
|
-
*
|
|
142
|
-
* `FilePermissionRuleStoreProvider`
|
|
141
|
+
* ✅ **#203 §1 已落 File 形**(那条注写下的解除条件逐字是「先落 File 形,core 现成
|
|
142
|
+
* `FilePermissionRuleStoreProvider` 即可当模子」——本车照办):`local` 现在返回一个**跨重启存活**的
|
|
143
|
+
* 三面束,storeWired 在单机车道上真为 true。in-memory twin 的禁令不变,它禁的是「会遗忘的店」,
|
|
144
|
+
* 不是「单机的店」。
|
|
143
145
|
*/
|
|
144
|
-
permissionRule():
|
|
146
|
+
permissionRule(): PermissionRuleStoreBundle | undefined;
|
|
145
147
|
/** SendUserFile scope↔object ledger (multi-tenant list/revoke handle; the hashed key segment hides the
|
|
146
148
|
* mapping from URLs). REQUIRED on all backends (works local — one JSONL, like approvalExemption). */
|
|
147
149
|
sendFileLedger(): SendFileLedger;
|
|
@@ -239,8 +241,12 @@ export declare function snapshotBoundsFromConfig(config: ServiceConfig): FileSna
|
|
|
239
241
|
*/
|
|
240
242
|
export declare function counterStoreLabel(store: unknown, kind: string | undefined): string;
|
|
241
243
|
/** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
|
|
242
|
-
* reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
|
|
243
|
-
|
|
244
|
+
* reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
|
|
245
|
+
*
|
|
246
|
+
* `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
|
|
247
|
+
* permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
|
|
248
|
+
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one. */
|
|
249
|
+
export declare function createStoreBackend(config: ServiceConfig, onWarn?: (msg: string, meta?: Record<string, unknown>) => void): StoreBackend | undefined;
|
|
244
250
|
/** boot 期打开 store 后端的**统一入口**:构造 + ensureSchema 同罩一层降级臂。
|
|
245
251
|
*
|
|
246
252
|
* 为什么必须把 `createStoreBackend()` 也罩进来(2026-07-28 修):clay 1.292 拍的口径是「裸 boot
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { FileStorageBackend, FileSessionRepo, DEFAULT_SNAPSHOT_BOUNDS } from "@sema-agent/core";
|
|
11
|
+
import { AdoptionError, FileStorageBackend, FileSessionRepo, DEFAULT_SNAPSHOT_BOUNDS } from "@sema-agent/core";
|
|
12
12
|
import { FileWorkflowJournalStore } from "@sema-agent/core";
|
|
13
13
|
import { TiDBOutcomeLedger, PgOutcomeLedger } from "./outcome-ledger-sql.js";
|
|
14
14
|
import { FileOutcomeSink } from "./file-outcome-sink.js";
|
|
@@ -43,7 +43,9 @@ import { PgSessionStore } from "./pg-session-storage.js";
|
|
|
43
43
|
import { TiDBSharedMemoryStore, PgSharedMemoryStore } from "./shared-memory-store-sql.js";
|
|
44
44
|
import { TiDBAdoptionLogStore, PgAdoptionLogStore } from "./adoption-log-sql.js";
|
|
45
45
|
// #154 车二:持久化权限规则店(三面一束)。`mysqlDriver`/`pgDriver` 是既有的方言中立 seam。
|
|
46
|
+
// #203 §1:local 车道的 File 三面束(core 现成 provider + 本仓 File 形审批记录/导入票)。
|
|
46
47
|
import { createSqlPermissionRuleStores } from "./permission-rule-store-sql.js";
|
|
48
|
+
import { createFilePermissionRuleStores } from "./permission-rule-store-file.js";
|
|
47
49
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
48
50
|
/** Build the snapshot-BLOB object-store backend from config, or undefined to fall back to the SqlBlobBackend default.
|
|
49
51
|
* When `config.snapshotBlobStore` (MINIO_* env) is set, the E19/2c file-snapshot stores route ONLY the blob BYTES to
|
|
@@ -202,13 +204,52 @@ class LocalBackend {
|
|
|
202
204
|
workflowJournalStore;
|
|
203
205
|
/** design/73 §1 — owner-only JSONL outcome-fact sink (stateless append; no fd held). */
|
|
204
206
|
outcomeSinkInst;
|
|
205
|
-
|
|
207
|
+
/** boot 期的告警口(`openStoreBackendWithFallback` 把它接到 logger.warn 上)。可选:直接 `new` 一只
|
|
208
|
+
* LocalBackend 的测试/工具不必装配日志面 —— 缺席时 core 的披露仍留在 core 那侧,只是本仓不复述。 */
|
|
209
|
+
onWarn;
|
|
210
|
+
constructor(root, config, onWarn) {
|
|
211
|
+
this.onWarn = onWarn;
|
|
206
212
|
// The fileBackend ctor mkdirs `root` + takes the boot pidfile lock — a same-host SECOND instance FAILS FAST.
|
|
207
213
|
// Re-throw with a clear operator message (the raw core error is "another instance owns this data dir").
|
|
214
|
+
//
|
|
215
|
+
// 🔴 core 5.23.0([3372] 提货批件④):这个构造器现在有**第二个**拒绝面 —— design/183 的 I6 adoption
|
|
216
|
+
// boot 门(`dist/stores/file/index.js:45` 的 `assertAdoptionBootGate`):数据根下 `adoption.json` 是
|
|
217
|
+
// 在飞标记 ⇒ `AdoptionError("adoption_in_flight")`,标记读不出 ⇒ `"adoption_marker_corrupt"`。
|
|
218
|
+
// 原来的 catch 是 **catch-all**,会把它一并改写成下面那句 boot-lock 文案 —— 那是**误诊**:它给的两条
|
|
219
|
+
// 出路对 adoption 在飞都不对,而「把 LOCAL_DATA_ROOT 指到一个空目录」更是直接劝运维**丢下一个迁移
|
|
220
|
+
// 到一半的数据根**;`AdoptionError` 的类型身份与 typed `code` 也在改写中被抹掉。
|
|
221
|
+
// ⇒ `AdoptionError` **原样穿过**:core 的拒绝句本就是写给人读的(点名在飞的那条弧,并给出「resume
|
|
222
|
+
// adoptLocalDataRoot 到完成,或修 adoption.json」的出路),我们没有更好的话可说,包一层只会更差。
|
|
223
|
+
// 包装只留给真正的 boot-lock 类失败(那句文案的适用条件)。
|
|
208
224
|
try {
|
|
209
|
-
this.fileBackend = new FileStorageBackend({
|
|
225
|
+
this.fileBackend = new FileStorageBackend({
|
|
226
|
+
root,
|
|
227
|
+
...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}), // REWIND_SNAPSHOT_MAX_MB
|
|
228
|
+
// 🔴 腐读披露座(交接件④,与 `run-local.ts` 的同座同形、同 warn 名 `file_store_corrupt_read`)。
|
|
229
|
+
// core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,
|
|
230
|
+
// 也会自锁修复写)。这条 fail-open 是 core 的裁定,本仓不改它;但它**不许无声**(#157 安全轴纪律)。
|
|
231
|
+
// 此前 HTTP 服务的 local 车道**根本没接这个座**:同一份坏字节,run-local 上打一行、服务上一个字
|
|
232
|
+
// 都没有 —— 而服务形恰恰是没人盯着终端的那一个。
|
|
233
|
+
//
|
|
234
|
+
// ⚠️ 文案按脸分列后果、**不下统一结论**(run-local 那次 codex R2-medium 的教训一并吸收):一个座
|
|
235
|
+
// 覆盖 core 转发的三张脸,后果各不相同 —— 把「少了会话规则这一层」写成「整个任务无约束」会误导
|
|
236
|
+
// 事故定级,而且对另外两张脸根本不成立。共同事实只有一句:一次持久读被当成了缺席。
|
|
237
|
+
// ENOENT 不触发(那是真缺席,不是腐读)。
|
|
238
|
+
onCorruptRead: (info) => this.onWarn?.("file_store_corrupt_read", {
|
|
239
|
+
path: info.path,
|
|
240
|
+
reason: info.reason,
|
|
241
|
+
...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
|
|
242
|
+
...(info.principal !== undefined ? { principal: info.principal } : {}),
|
|
243
|
+
note: "a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
|
|
244
|
+
"Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
|
|
245
|
+
"layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
|
|
246
|
+
"rows; file-snapshot = a snapshot or scope read as missing. Inspect the named path before deciding.",
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
210
249
|
}
|
|
211
250
|
catch (e) {
|
|
251
|
+
if (e instanceof AdoptionError)
|
|
252
|
+
throw e;
|
|
212
253
|
throw new Error(`DB_BACKEND=local cannot open data dir ${root}: ${e instanceof Error ? e.message : String(e)} ` +
|
|
213
254
|
"(another HTTP service / run-local instance may own it; stop it first or set LOCAL_DATA_ROOT to a free dir)");
|
|
214
255
|
}
|
|
@@ -235,6 +276,10 @@ class LocalBackend {
|
|
|
235
276
|
this.sendFileLedgerStore.dispose(); // release the sendfile-ledger append fd (lazy — may never have opened)
|
|
236
277
|
this.workflowJournalStore.dispose(); // release the per-run journal-ledger fds
|
|
237
278
|
this.checkpointStoreInst?.close(); // release the checkpoint ledger fd (lazy, may never have been built)
|
|
279
|
+
// #203 §1: release core's permission-rule DIRECTORY write lock (lazy — may never have been built).
|
|
280
|
+
// Same discipline as root/LOCK below: an un-released writer lock tells the next boot that a live process
|
|
281
|
+
// owns the rule directory, and core refuses the second holder LOUDLY rather than queueing.
|
|
282
|
+
this.permissionRuleStoresInst?.dispose();
|
|
238
283
|
await this.fileBackend.dispose(); // MUST release root/LOCK so a graceful restart can re-acquire the data dir
|
|
239
284
|
}
|
|
240
285
|
run() { return this.runStore; }
|
|
@@ -244,9 +289,22 @@ class LocalBackend {
|
|
|
244
289
|
* new 一个就等于每个消费者各拿一份互不可见的账(而 SQL twin 天然共享一张表)。易失代价 +
|
|
245
290
|
* 「local 车道默认不上协议」的裁定见 StoreBackend.approvalAsk() 的接口注。 */
|
|
246
291
|
approvalAsk() { return this.approvalAskStore; }
|
|
247
|
-
/** #
|
|
248
|
-
*
|
|
249
|
-
|
|
292
|
+
/** #203 §1:local 车道的 File 三面束。**必须**是 singleton —— core 的 File provider 在第一次取写面时
|
|
293
|
+
* 对规则目录取一把进程级写锁,第二个持有者被**响亮拒绝**(不是排队),所以每次 `new` 一个就等于给
|
|
294
|
+
* 自己造一次「另一个进程占着这个目录」的假象。`close()` 里 `dispose()` 释放它(与数据根 `root/LOCK`
|
|
295
|
+
* 同一条纪律:不释放 ⇒ 优雅重启被自己上一条命留下的锁挡在门外)。惰性建:没开旋钮的部署不该因为
|
|
296
|
+
* 一个从不使用的面而在数据根里长出目录、更不该白占一把写锁。 */
|
|
297
|
+
permissionRule() {
|
|
298
|
+
if (!this.permissionRuleStoresInst) {
|
|
299
|
+
this.permissionRuleStoresInst = createFilePermissionRuleStores(this.fileBackend.root, {
|
|
300
|
+
// core 规则文件的披露面(读不出 / 校验和不符 / 撞上符号链接 ⇒ 答零规则**并说出来**)。
|
|
301
|
+
// 不接住它就是一次静默的 fail-closed:用户突然被反复询问,而日志里一个字都没有。
|
|
302
|
+
onError: (message) => this.onWarn?.("permission_rule_store_disclosure", { message }),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return this.permissionRuleStoresInst;
|
|
306
|
+
}
|
|
307
|
+
permissionRuleStoresInst;
|
|
250
308
|
sendFileLedger() { return this.sendFileLedgerStore; } // singleton — append fd lives in the instance
|
|
251
309
|
/** Durable HITL / plan-review parking on the LOCAL lane. Lazy singleton (the inner core store holds
|
|
252
310
|
* an append fd). taskId join ← FileRunStore; E21 owner guard ← LocalSessionStore.ownerOf (same root). */
|
|
@@ -302,10 +360,14 @@ export function counterStoreLabel(store, kind) {
|
|
|
302
360
|
return store ? `shared(${kind ?? "unknown"})` : "in-process";
|
|
303
361
|
}
|
|
304
362
|
/** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
|
|
305
|
-
* reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
|
|
306
|
-
|
|
363
|
+
* reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
|
|
364
|
+
*
|
|
365
|
+
* `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
|
|
366
|
+
* permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
|
|
367
|
+
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one. */
|
|
368
|
+
export function createStoreBackend(config, onWarn) {
|
|
307
369
|
if (config.dbBackend === "local")
|
|
308
|
-
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config);
|
|
370
|
+
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn);
|
|
309
371
|
if (config.dbBackend === "pg") {
|
|
310
372
|
if (!config.pg)
|
|
311
373
|
return undefined;
|
|
@@ -335,12 +397,22 @@ export async function openStoreBackendWithFallback(config, logger) {
|
|
|
335
397
|
const mayFallback = config.dbBackend === "local" ? !config.dbBackendExplicit : config.sessionBackend === "auto";
|
|
336
398
|
let backend;
|
|
337
399
|
try {
|
|
338
|
-
|
|
400
|
+
// 包一层箭头而不是直接传 `logger.warn`:后者会把方法从它的接收者上摘下来,一个带 `this` 的
|
|
401
|
+
// logger 实现会在第一次真披露时炸——而那一刻正是最不该炸的时候(本仓 fail-loud 病族先例)。
|
|
402
|
+
backend = createStoreBackend(config, (msg, meta) => logger.warn(msg, meta));
|
|
339
403
|
// connects + creates schema (also the reachability probe).
|
|
340
404
|
if (backend)
|
|
341
405
|
await backend.ensureSchema();
|
|
342
406
|
}
|
|
343
407
|
catch (err) {
|
|
408
|
+
// 🔴 core 5.23.0([3372] 提货批,codex 复审 high 抓获、验真后修):**I6 adoption 门不许被降级臂吃掉**。
|
|
409
|
+
// 1.292 拍的降级裁定说的是「只读 FS / mkdir 失败」那一类——它们的意思是「这台机器存不了盘」,降级到
|
|
410
|
+
// 内存是诚实的等价物。`AdoptionError` 说的是完全不同的一句话:「这份数据**有主、正在搬**」(design/183
|
|
411
|
+
// I6:数据根的 `adoption.json` 在飞,或标记读不出)。对后者降级 = **在搬家途中另开一个写者**,而那正是
|
|
412
|
+
// I6 门存在的全部理由;更坏的是它还会留下一行「DB 不可达」的 warn,把运维支去查连通性。
|
|
413
|
+
// ⇒ 与显式 DB_BACKEND 同待遇:无条件 fail-loud,穿过 `mayFallback`。
|
|
414
|
+
if (err instanceof AdoptionError)
|
|
415
|
+
throw err;
|
|
344
416
|
if (!mayFallback)
|
|
345
417
|
throw err;
|
|
346
418
|
// S5 (SILENT-FALLBACK P0-b): one boot warn was the ONLY trace of "this replica is not persisting".
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ImportPreview, type ImportResult, type ImportedSettingsLayer, type RuleScope } from "@sema-agent/core";
|
|
1
|
+
import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type RuleScope } from "@sema-agent/core";
|
|
2
2
|
import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
|
|
3
3
|
import { type RuleImportTicket, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
|
|
4
4
|
/**
|
|
@@ -35,6 +35,29 @@ export interface RuleConsentStores {
|
|
|
35
35
|
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 一个 backend 交出来的**完整**规则店束 = 同意车道要的三面 + boot 期审计要的窄读口。
|
|
40
|
+
*
|
|
41
|
+
* 🔴 为什么它住在这里而不是某个 `plugins/*-sql.ts`:`StoreBackend.permissionRule()` 现在有**两个**
|
|
42
|
+
* 实现(SQL 双方言 + local File),把返回类型钉成其中任何一个的具体类型都会让另一个只能靠断言挤进去。
|
|
43
|
+
* 结构接口是这条 seam 唯一站得住的形——两个实现各自满足它,消费点(main 装配 / 同意车道 / 撤销面
|
|
44
|
+
* 路由)一个字都不用知道行躺在哪里。
|
|
45
|
+
*/
|
|
46
|
+
export interface PermissionRuleStoreBundle extends RuleConsentStores {
|
|
47
|
+
/** #203 §3:库里已有几只规则桶(一 owner 一桶)。boot 期休眠行审计的**唯一**依赖;
|
|
48
|
+
* 为什么数桶不数规则、以及读失败必须响亮,见 `boot/permission-rules-audit.ts` 与两个实现处的注。 */
|
|
49
|
+
countBuckets(): Promise<number>;
|
|
50
|
+
/**
|
|
51
|
+
* A-010.17 保留期腿:清掉**可证已死**的导入票与孤儿 pending 审批记录,返回删掉的总行数。
|
|
52
|
+
* 谓词与「为什么不需要旋钮」逐字见 `plugins/permission-rule-store-sql.ts` 的同名成员。
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **可选**,而缺席是**如实登记**过的:File 车道(`permission-rule-store-file.ts`)的审批/票两面是
|
|
55
|
+
* **追加日志**,删一行等于重写整个日志 —— 那是一次压实(compaction)设计,与 SQL 的一条 DELETE 不是
|
|
56
|
+
* 同一件事,不在本条射程内。缺席的实际后果有界:File 车道 = 单用户本地形,两只日志的增长由**一个人**
|
|
57
|
+
* 的导入次数封顶,不是多租户共享库那种无界增长。要做压实时在这里补实现,消费点(reaper)零改动。
|
|
58
|
+
*/
|
|
59
|
+
reapExpired?(nowMs: number): Promise<number>;
|
|
60
|
+
}
|
|
38
61
|
/** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
|
|
39
62
|
* 窄到一张泄漏的票不会常驻。运维旋钮暂不开(没有部署形要求它可调;要开时走 config.ts 的既有姿势)。 */
|
|
40
63
|
export declare const RULE_IMPORT_TICKET_TTL_MS: number;
|
|
@@ -101,6 +124,53 @@ export interface RuleImportLayerInput {
|
|
|
101
124
|
root: string;
|
|
102
125
|
content: string;
|
|
103
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* scope 的 **wire 形**:`global` | `project:<root>` 的判别式串(design/203 v2 §6 F5「显式成文」)。
|
|
129
|
+
*
|
|
130
|
+
* 🔴 为什么是一个串而不是把 core 的 `{kind, root}` 对象直接上 wire:这一份表示要同时当**列举结果里的
|
|
131
|
+
* 一列**、**查询参数**、**删除请求体的一个字段**和**分页游标的一部分**。四处若各用各的形,「删掉我刚才
|
|
132
|
+
* 列出来的那一行」就变成一次跨形状翻译 —— 而那正是最容易漂的地方。判别式串在四处逐字相同,而且
|
|
133
|
+
* `project:` 前缀让「这条规则是项目内的」在一眼扫日志时就成立。
|
|
134
|
+
*
|
|
135
|
+
* `root` 里可以含冒号(路径合法),所以只切**第一个**冒号 —— 用 `split(":")` 取 [1] 会把
|
|
136
|
+
* `project:/a:b` 悄悄截成 `/a`,那是一条**放宽面**上的静默改写(更短的 root 覆盖更多 cwd)。
|
|
137
|
+
*/
|
|
138
|
+
export declare function serializeRuleScope(scope: RuleScope): string;
|
|
139
|
+
/** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
|
|
140
|
+
* 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。 */
|
|
141
|
+
export declare function parseRuleScope(text: string): RuleScope | undefined;
|
|
142
|
+
/** 一条规则在 wire 上的行 = 一个逻辑 (rule, scope) 对(design/203 v2 §6 F5)。
|
|
143
|
+
* `adds` **如实上 wire**、不折叠成单值:同一条规则可以被批准过多次(不同 dot、不同来源、不同时刻),
|
|
144
|
+
* 折成「一个 addedAt」会让「这条规则是我导入的还是我点过卡的」在治理面上不可分。 */
|
|
145
|
+
export interface PersistedRuleWireRow {
|
|
146
|
+
rule: string;
|
|
147
|
+
/** {@link serializeRuleScope} 的判别式串。 */
|
|
148
|
+
scope: string;
|
|
149
|
+
tool: PersistedAllowRule["tool"];
|
|
150
|
+
match: PersistedAllowRule["match"];
|
|
151
|
+
/** 去规范化的命令(core 的匹配器信的就是它 + `match`,不是重新解析 `rule`)。 */
|
|
152
|
+
command: string;
|
|
153
|
+
adds: PersistedAllowRule["adds"];
|
|
154
|
+
}
|
|
155
|
+
/** 列举结果。`rev` = 这只桶的 OCC 版本,游标绑它(见路由侧的游标注)。 */
|
|
156
|
+
export interface PersistedRuleListing {
|
|
157
|
+
rows: PersistedRuleWireRow[];
|
|
158
|
+
rev: number;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 撤销面两口寻址的**桶**(#203 收官件,core 5.24.0 提货批 [3427])。
|
|
162
|
+
*
|
|
163
|
+
* 裸串 = principal 简写(既有调用方逐字不变);结构形 {@link RuleOwner} 多出 `{kind:"local-owner"}`
|
|
164
|
+
* —— 身份缺席的单机形自己那只桶。这不是 server 发明的第二种寻址:core 的
|
|
165
|
+
* `PermissionRuleStoreProvider.forLocalOwner()`(design/182 §4.5)一直就有这只桶,缺的是**撤销入口
|
|
166
|
+
* 叫不出它的名字** —— 我方 [3399]① 请托、core 5.24.0 把 `removePersistedRule` 的 `principal` 入参扩成
|
|
167
|
+
* `string | RuleOwner` 兑现(d.ts 那段注逐字写着「downstream request, 2026-08-10」)。
|
|
168
|
+
*
|
|
169
|
+
* 🔴 **绝不用哨兵串**表示身份缺席(core 的 `RuleOwner` 头注就是这条纪律的出处):`forPrincipal("")` /
|
|
170
|
+
* `forPrincipal("local-owner")` 都会让一个**编出来的名字**流进身份面,而身份面上编的名字与真名在下游
|
|
171
|
+
* 不可分。判别式联合是唯一诚实的形。
|
|
172
|
+
*/
|
|
173
|
+
export type RuleBucketRef = string | RuleOwner;
|
|
104
174
|
export interface RuleConsentLane {
|
|
105
175
|
/** 卡道兑付口:一次 ask 决议携规则确认 ⇒ prepare→confirm→redeem 一气呵成。 */
|
|
106
176
|
persistCardRule(input: {
|
|
@@ -118,6 +188,33 @@ export interface RuleConsentLane {
|
|
|
118
188
|
prepareImport(principal: string, layers: readonly RuleImportLayerInput[]): Promise<RuleImportPrepared>;
|
|
119
189
|
/** 导入道:原子消费票 → confirm → redeemRuleBatch。 */
|
|
120
190
|
redeemImport(principal: string, ticket: string): Promise<RuleImportRedeemed>;
|
|
191
|
+
/**
|
|
192
|
+
* 撤销面读半场:列出一只桶名下**活着**的规则(墓碑已折算)。
|
|
193
|
+
*
|
|
194
|
+
* 排序 = (scope, rule) 字典序,**确定性**:分页游标是 keyset 形,而 keyset 的全部前提就是「同一份
|
|
195
|
+
* 数据每次以同一个顺序出现」。core 的 `list()` 不承诺顺序(SQL 形按写入顺序、File 形按文件内顺序),
|
|
196
|
+
* 所以序在这里定,不在店里。
|
|
197
|
+
*
|
|
198
|
+
* 入参裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形另开 local-owner 桶。
|
|
199
|
+
*/
|
|
200
|
+
listRules(bucket: RuleBucketRef): Promise<PersistedRuleListing>;
|
|
201
|
+
/**
|
|
202
|
+
* 撤销面写半场。**恒经 core `removePersistedRule`**(design/203 §2 裁定)——它产墓碑,而墓碑是
|
|
203
|
+
* `screenRuleSyncState` 的筛子保证「别的副本不把这条规则回灌回来」的唯一凭据。直接删 SQL 行/文件行
|
|
204
|
+
* 是**无墓碑硬删**:sync 的 join 会让它复活,而运维以为自己收回了权限。
|
|
205
|
+
*
|
|
206
|
+
* 返回值逐字是 core 的 `RemoveResult` 三态(removed / no-op / failed),**不塌**:
|
|
207
|
+
* `removed.stillLive:true` 意思是「墓碑落了,但本次调用期间又落了一次新的批准,规则按 add-wins
|
|
208
|
+
* 仍然活着」——那是一个**有名字的真结果**,不是异常,更不是「撤销完成」。
|
|
209
|
+
*
|
|
210
|
+
* `principal` 裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形让
|
|
211
|
+
* local-owner 桶**真可撤**(core 5.24.0 起 `removePersistedRule` 的入参 union 扩,#203 收官件)。
|
|
212
|
+
*/
|
|
213
|
+
removeRule(input: {
|
|
214
|
+
principal: RuleBucketRef;
|
|
215
|
+
rule: string;
|
|
216
|
+
scope: RuleScope;
|
|
217
|
+
}): Promise<RemoveResult>;
|
|
121
218
|
}
|
|
122
219
|
export declare function createRuleConsentLane(stores: RuleConsentStores, opts?: {
|
|
123
220
|
ticketTtlMs?: number;
|
package/dist/rules-consent.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { recordFailOpen } from "./observability/fail-open.js";
|
|
38
|
-
import { confirmRuleApproval, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, } from "@sema-agent/core";
|
|
38
|
+
import { confirmRuleApproval, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, } from "@sema-agent/core";
|
|
39
39
|
import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
|
|
40
40
|
/** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
|
|
41
41
|
* 窄到一张泄漏的票不会常驻。运维旋钮暂不开(没有部署形要求它可调;要开时走 config.ts 的既有姿势)。 */
|
|
@@ -58,6 +58,71 @@ export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
|
|
|
58
58
|
* 「几百次 SQL」而不是「几万次」。
|
|
59
59
|
*/
|
|
60
60
|
export const MAX_IMPORT_CANDIDATES = 200;
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// #203 §2 —— 撤销面(`GET /v1/rules` + `DELETE /v1/rules`)的车道半场
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* scope 的 **wire 形**:`global` | `project:<root>` 的判别式串(design/203 v2 §6 F5「显式成文」)。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 为什么是一个串而不是把 core 的 `{kind, root}` 对象直接上 wire:这一份表示要同时当**列举结果里的
|
|
68
|
+
* 一列**、**查询参数**、**删除请求体的一个字段**和**分页游标的一部分**。四处若各用各的形,「删掉我刚才
|
|
69
|
+
* 列出来的那一行」就变成一次跨形状翻译 —— 而那正是最容易漂的地方。判别式串在四处逐字相同,而且
|
|
70
|
+
* `project:` 前缀让「这条规则是项目内的」在一眼扫日志时就成立。
|
|
71
|
+
*
|
|
72
|
+
* `root` 里可以含冒号(路径合法),所以只切**第一个**冒号 —— 用 `split(":")` 取 [1] 会把
|
|
73
|
+
* `project:/a:b` 悄悄截成 `/a`,那是一条**放宽面**上的静默改写(更短的 root 覆盖更多 cwd)。
|
|
74
|
+
*/
|
|
75
|
+
export function serializeRuleScope(scope) {
|
|
76
|
+
return scope.kind === "global" ? "global" : `project:${scope.root}`;
|
|
77
|
+
}
|
|
78
|
+
/** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
|
|
79
|
+
* 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。 */
|
|
80
|
+
export function parseRuleScope(text) {
|
|
81
|
+
if (text === "global")
|
|
82
|
+
return { kind: "global" };
|
|
83
|
+
if (!text.startsWith("project:"))
|
|
84
|
+
return undefined;
|
|
85
|
+
const root = text.slice("project:".length);
|
|
86
|
+
// 空 root 硬拒:`{kind:"project", root:""}` 在 core 的 `pathWithinRoot` 下**包含每一个 cwd**
|
|
87
|
+
// (空前缀包含一切)——一条项目内规则就此悄悄升成全局规则(cc-import 那侧同判据,同一条 codex 发现)。
|
|
88
|
+
return root === "" ? undefined : { kind: "project", root };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 桶引用 → 具体的规则店。
|
|
92
|
+
*
|
|
93
|
+
* 🔴 **穷举判别,未知形一律抛**(CLAUDE.md 词表闭集律 + codex 对抗复审 R3-[medium],验真后改)。
|
|
94
|
+
* 第一稿写的是 `kind === "principal" ? 甲 : 乙` 的二分:TS 那侧是穷举的,**运行期不是** —— 本车道是
|
|
95
|
+
* 一个**导出**的束,调用方可以是任何一层(HTTP 路由今天只递裸串,但这条口正是为了将来递结构形才开的)。
|
|
96
|
+
* 于是 `{kind:"principle"}`(拼错)或 `{}` 会静默落进 local-owner 那支 = 一次**跨桶读**;更坏的是
|
|
97
|
+
* 同一个坏值递给 core 的 `removePersistedRule` 时它按 `kind === "local-owner"` 反着判,落到
|
|
98
|
+
* `forPrincipal(undefined)`(空店)—— 列举和撤销**指向两只不同的桶**,而两边都不报错。
|
|
99
|
+
* 身份面上「不认识的形」只有一个正确答案:响亮拒。
|
|
100
|
+
*
|
|
101
|
+
* 🔴 local-owner 桶而 provider 没有 `forLocalOwner` ⇒ 同样**抛**,不静默回落到别的桶。回落到
|
|
102
|
+
* `forPrincipal(…)` 会把「读/删身份缺席桶」变成「读/删某个 principal 的桶」——那是一次跨桶的读或写,
|
|
103
|
+
* 是本车道最不该出的错。报文与 core 的 `removePersistedRule` 同支同措辞(那侧回 `failed` 带
|
|
104
|
+
* `forLocalOwner` 字样),两侧一起读日志时对得上。
|
|
105
|
+
*/
|
|
106
|
+
function storeForBucket(provider, ref) {
|
|
107
|
+
// 裸串 = principal 简写(core `removePersistedRule` 的同款归一,两侧对同一个输入判同一只桶)。
|
|
108
|
+
if (typeof ref === "string")
|
|
109
|
+
return provider.forPrincipal(ref);
|
|
110
|
+
// `ref.kind` 在类型上是闭集;`switch` 的 default 是**运行期**的那道门(不是死码)。
|
|
111
|
+
switch (ref.kind) {
|
|
112
|
+
case "principal":
|
|
113
|
+
return provider.forPrincipal(ref.principal);
|
|
114
|
+
case "local-owner":
|
|
115
|
+
if (provider.forLocalOwner === undefined) {
|
|
116
|
+
throw new Error("this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be listed or removed through it");
|
|
117
|
+
}
|
|
118
|
+
return provider.forLocalOwner();
|
|
119
|
+
default: {
|
|
120
|
+
// core 给 `RuleOwner` 加员时这里是**编译**红(`never` 赋值失败),不是静默继承某一支。
|
|
121
|
+
const unknown = ref;
|
|
122
|
+
throw new Error(`unrecognised permission-rule bucket reference (${JSON.stringify(unknown)}) — refusing to guess which bucket it names`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
61
126
|
export function createRuleConsentLane(stores, opts) {
|
|
62
127
|
const deps = { provider: stores.provider, approvals: stores.approvals };
|
|
63
128
|
const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
|
|
@@ -193,6 +258,24 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
193
258
|
return await indeterminate(err instanceof Error ? err.message : String(err));
|
|
194
259
|
}
|
|
195
260
|
},
|
|
261
|
+
async listRules(bucket) {
|
|
262
|
+
// `list()` 已经把墓碑折算掉(`applyTombstones`)⇒ 这里拿到的就是**活着**的规则,治理面看到的
|
|
263
|
+
// 与引擎放行时看到的是同一份。空桶 = 空表 + rev 0,不是错误(「这个人没有规则」是合法状态)。
|
|
264
|
+
const stored = await storeForBucket(stores.provider, bucket).list();
|
|
265
|
+
const rows = stored.rules.map((r) => ({ rule: r.rule, scope: serializeRuleScope(r.scope), tool: r.tool, match: r.match, command: r.command, adds: r.adds }));
|
|
266
|
+
rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0));
|
|
267
|
+
return { rows, rev: stored.rev };
|
|
268
|
+
},
|
|
269
|
+
async removeRule(input) {
|
|
270
|
+
// 🔴 一行也不自己写:OCC 重试、墓碑铸造、`stillLive` 的读回全在 core 那条原语里,而它的语义
|
|
271
|
+
// (add-wins、observed-remove、失败必须由读回确认「什么都没写」)是 core 的单一属主。
|
|
272
|
+
// 桶引用**原样**递下去(裸串走 core 的 principal 简写臂,结构形走 owner 臂)——我方不在这里
|
|
273
|
+
// 归一成 owner 对象:core 5.24.0 的 `removePersistedRule` 自己就做那次归一(`typeof === "string"`),
|
|
274
|
+
// 多归一一次等于把上游的简写语义抄一份到下游,抄件会漂。local-owner 而 provider 无 `forLocalOwner`
|
|
275
|
+
// 的那支也归 core:它回的是 `failed` + 报文含 `forLocalOwner`(C13 判据),比抛错更适合这条
|
|
276
|
+
// 三态返回的口。
|
|
277
|
+
return await removePersistedRule({ rule: input.rule, scope: input.scope, principal: input.principal, provider: stores.provider });
|
|
278
|
+
},
|
|
196
279
|
};
|
|
197
280
|
}
|
|
198
281
|
//# sourceMappingURL=rules-consent.js.map
|