@sema-agent/server 7.11.0 → 7.12.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 +56 -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 +57 -0
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +17 -9
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -79
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +44 -9
- package/dist/http/routes/rules.d.ts +19 -7
- package/dist/http/routes/rules.js +180 -4
- package/dist/http/server.d.ts +4 -2
- package/dist/http/server.js +81 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +19 -1
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -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/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 +7 -0
- package/dist/plugins/permission-rule-store-sql.js +11 -0
- package/dist/plugins/store-backend.d.ts +12 -6
- package/dist/plugins/store-backend.js +58 -9
- package/dist/rules-consent.d.ts +69 -1
- package/dist/rules-consent.js +43 -1
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +6 -1
- package/dist/tool-approval.js +106 -27
- package/dist/trace/core-keyset-guard.d.ts +13 -2
- package/dist/trace/project.d.ts +19 -2
- package/dist/trace/project.js +24 -4
- package/package.json +2 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #203 §1(design/203 v2 §6 F1)—— 持久化权限规则店的 **local(File)三面束**。
|
|
3
|
+
*
|
|
4
|
+
* 车二在 `store-backend.ts` 上写下的那句「local 车道诚实缺席」有一条**明写的解除条件**:「要在 local
|
|
5
|
+
* 真上,先落 File 形(core 现成 `FilePermissionRuleStoreProvider` 即可当模子)」。本文件就是那一件 ——
|
|
6
|
+
* 三面里**规则桶那一面直接接线 core 的现成件**(不是照抄:它自带符号链接拒收、写锁、整文件校验和、
|
|
7
|
+
* 损坏即整份拒读,那几样都不是能顺手复刻对的东西),另外两面(审批记录 / 导入票)core 只给了
|
|
8
|
+
* `InMemory` 参照物,所以在这里落 File 形。
|
|
9
|
+
*
|
|
10
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* 🔴 为什么 File 形的「单进程内 CAS」是**够的**(而不是一次偷工)
|
|
12
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* SQL 形把 CAS 交给引擎的行锁,是因为多副本共享一张表。local 车道不是那个形状:`LocalBackend` 的构造
|
|
14
|
+
* 函数在数据根上取 `root/LOCK` 这只 boot pidfile 锁,**同主机第二个实例起不来**(store-backend.ts 的
|
|
15
|
+
* `LocalBackend` 头注逐字);core 的 `FilePermissionRuleStoreProvider` 自己还在规则目录上另取一把写锁,
|
|
16
|
+
* 第二个持有者被响亮拒绝而不是交错写。⇒ 在这条车道上「同一时刻只有一个写者」是**被强制**的,不是被
|
|
17
|
+
* 假设的,于是进程内的 `rev` 比较就是一次真 compare-and-set。
|
|
18
|
+
* 这条推理有一个已登记的边界(与 core 的 File 规则店同源、不是本文件新增的):跨 PID 命名空间或 NFS
|
|
19
|
+
* 上 `process.kill(pid,0)` 的活体判据会失效(见 `docs/DEPLOY-PREREQS.md`)。那时该换 SQL 后端 ——
|
|
20
|
+
* 这正是 store seam 存在的理由。
|
|
21
|
+
*
|
|
22
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
* 🔴 落盘形:一票据/一记录一行 JSONL 追加日志 + boot 重放,**不是**整文件覆写
|
|
24
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
* 与 `FileApprovalExemptionStore` / `FileSendFileLedger` 同一个模子(core 的 `AppendLog` /
|
|
26
|
+
* `readJsonlRecords`:追加 + fsync,撕裂的尾行在重放时被丢掉而不是被猜出来)。选它而不是「整份 JSON
|
|
27
|
+
* 原子改名」的理由是**这两面的写都是逐条的**(一次审批一条、一张票一条),整份覆写会让一次写的代价随
|
|
28
|
+
* 历史长度线性涨,而且把「两条无关记录」放进同一次 CAS 窗口。
|
|
29
|
+
* 代价如实登记:日志只增不减(没有紧凑腿)。票有 TTL、记录是审计事实,单用户一台机器上的量级是每天
|
|
30
|
+
* 几条到几十条 —— 真长到要紧凑时,该做的是紧凑腿(后续件),不是现在为它牺牲崩溃安全。
|
|
31
|
+
*
|
|
32
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
* 🔴 状态在**内存索引**里,磁盘是它的重放源
|
|
34
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
* 判决(CAS 成不成、票能不能认领)一律读内存索引,写成功后**先追加日志再改索引** —— 反过来(先改索引
|
|
36
|
+
* 再写盘)会在写失败时留下一个「进程认为已经发生、盘上没有」的事实,重启即回滚,而中间那段时间里
|
|
37
|
+
* 一次已经被消费掉的票会被当成还能用。
|
|
38
|
+
*/
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { readdirSync } from "node:fs";
|
|
41
|
+
import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// 落盘记录的边界 schema(宪法 [2704]:边界必 schema,禁裸 as-cast)
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
//
|
|
48
|
+
// 🔴 这**是**一条边界:磁盘上的字节可能来自上一个版本、来自一次手改、来自一次撕裂写。读回来的东西
|
|
49
|
+
// 若被 as-cast 成域类型,一条缺字段的记录会带着 `undefined` 走进 CAS 与兑付判决。判据用 zod。
|
|
50
|
+
//
|
|
51
|
+
// 🔴 读不出形的**内部**行 ⇒ 整面 fail-closed(codex 对抗复审 R1-F4,验真后修)。
|
|
52
|
+
// 本文件的初版写的是「坏行跳过 —— 一行是一条独立事实」,那句话是**错的**,而且错在最贵的地方:
|
|
53
|
+
// 票日志上的 mint / consume / release 是**同一张票的状态迁移**。一条 `consume` 坏掉而它前面的 `mint`
|
|
54
|
+
// 还读得出 ⇒ 重启后那张票复活成**未认领**,「一次性消费」这条信任边界当场失效(SQL 形靠引擎行锁,
|
|
55
|
+
// 永远不会有这种形)。审批记录同族:一条坏掉的快照会把记录连同 `rev` 回滚到更早的状态。
|
|
56
|
+
// 处置与 core 对**规则文件**的姿势对齐 ——「refusing the whole bucket rather than reporting a partial
|
|
57
|
+
// rule set」:整面拒 + 响亮留痕。失败方向是收紧(兑不动 ⇒ 多问几次),这正是这条轴该倒的方向。
|
|
58
|
+
// **撕裂的尾行不算**:core 的 `readJsonlRecords` 按崩溃尾丢弃它并且**不**报 `onCorrupt` —— 那是每次
|
|
59
|
+
// 非优雅退出的正常落盘形,把它算成损坏会让一次 kill -9 永久锁死这条车道。
|
|
60
|
+
const RuleScopeSchema = z.union([
|
|
61
|
+
z.object({ kind: z.literal("global") }).strict(),
|
|
62
|
+
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
63
|
+
]);
|
|
64
|
+
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
65
|
+
const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
|
|
66
|
+
const ApprovalRecordSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
id: z.string().min(1),
|
|
69
|
+
principal: z.string().optional(),
|
|
70
|
+
owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
|
|
71
|
+
kind: z.enum(["card", "import", "starter"]),
|
|
72
|
+
state: z.enum(["pending", "approved", "redeemed"]),
|
|
73
|
+
candidates: z.array(RuleCandidateSchema),
|
|
74
|
+
createdAt: z.string(),
|
|
75
|
+
toolCallId: z.string().optional(),
|
|
76
|
+
boundInputHash: z.string().optional(),
|
|
77
|
+
rev: z.number().int().nonnegative().safe(),
|
|
78
|
+
selectedCandidate: z.number().int().nonnegative().safe().optional(),
|
|
79
|
+
redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
|
|
80
|
+
})
|
|
81
|
+
.strict();
|
|
82
|
+
/**
|
|
83
|
+
* 校验产物 → 域类型的**显式**投影。
|
|
84
|
+
*
|
|
85
|
+
* 🔴 为什么是一个函数而不是 `.transform(...)` 上挂一个断言:`RuleApprovalRecord.redeemedDots` 的键是
|
|
86
|
+
* `number`,而 JSON 对象的键恒是字符串 —— zod 只表达得出 `Record<string, …>`,于是「用 zod 直接声明成
|
|
87
|
+
* 域类型」必然要一次 `as unknown as`,那正是宪法禁的裸断言。逐字段抄写换来的是:core 给
|
|
88
|
+
* `RuleApprovalRecord` 加一个必填字段时,**这里编译红**(而断言形会静默放行一条缺字段的记录)。
|
|
89
|
+
* 数字键的还原与 SQL 侧 `redeemed_dots_json` 的回读逐字同一处置。
|
|
90
|
+
*/
|
|
91
|
+
function toApprovalRecord(row) {
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
...(row.principal !== undefined ? { principal: row.principal } : {}),
|
|
95
|
+
...(row.owner !== undefined ? { owner: row.owner } : {}),
|
|
96
|
+
kind: row.kind,
|
|
97
|
+
state: row.state,
|
|
98
|
+
candidates: row.candidates,
|
|
99
|
+
createdAt: row.createdAt,
|
|
100
|
+
...(row.toolCallId !== undefined ? { toolCallId: row.toolCallId } : {}),
|
|
101
|
+
...(row.boundInputHash !== undefined ? { boundInputHash: row.boundInputHash } : {}),
|
|
102
|
+
rev: row.rev,
|
|
103
|
+
...(row.selectedCandidate !== undefined ? { selectedCandidate: row.selectedCandidate } : {}),
|
|
104
|
+
...(row.redeemedDots !== undefined
|
|
105
|
+
? { redeemedDots: Object.fromEntries(Object.entries(row.redeemedDots).map(([k, v]) => [Number(k), v])) }
|
|
106
|
+
: {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** 审批记录日志的一行:整条记录的快照(last-wins 重放)或一条丢弃墓碑。 */
|
|
110
|
+
const ApprovalLineSchema = z.union([
|
|
111
|
+
z.object({ op: z.literal("put"), record: ApprovalRecordSchema }).strict(),
|
|
112
|
+
z.object({ op: z.literal("discard"), id: z.string().min(1) }).strict(),
|
|
113
|
+
]);
|
|
114
|
+
/** 票日志的一行:铸票 / 认领 / 放回。三个动作各一行,重放顺序即真相。 */
|
|
115
|
+
const TicketLineSchema = z.union([
|
|
116
|
+
z
|
|
117
|
+
.object({
|
|
118
|
+
op: z.literal("mint"),
|
|
119
|
+
ticketId: z.string().min(1),
|
|
120
|
+
ownerKey: z.string().min(1),
|
|
121
|
+
approvalId: z.string().min(1),
|
|
122
|
+
payloadHash: z.string().min(1),
|
|
123
|
+
expiresAtMs: z.number().int().nonnegative().safe(),
|
|
124
|
+
})
|
|
125
|
+
.strict(),
|
|
126
|
+
z.object({ op: z.literal("consume"), ticketId: z.string().min(1), atMs: z.number().int().nonnegative().safe() }).strict(),
|
|
127
|
+
z.object({ op: z.literal("release"), ticketId: z.string().min(1) }).strict(),
|
|
128
|
+
]);
|
|
129
|
+
/**
|
|
130
|
+
* 一面的**损坏闸**。置位之后该面的每一个方法都响亮拒绝 —— 读与写都拒:一份不完整的授权账既不能用来
|
|
131
|
+
* 判决,也不能在它上面继续追加(追加只会让下一次重启读到同样残缺的历史)。
|
|
132
|
+
*/
|
|
133
|
+
class CorruptionGate {
|
|
134
|
+
face;
|
|
135
|
+
path;
|
|
136
|
+
onError;
|
|
137
|
+
reason;
|
|
138
|
+
constructor(face, path, onError) {
|
|
139
|
+
this.face = face;
|
|
140
|
+
this.path = path;
|
|
141
|
+
this.onError = onError;
|
|
142
|
+
}
|
|
143
|
+
/** 记一次内部损坏(幂等:第一条原因就是要报告的那条)。 */
|
|
144
|
+
trip(reason) {
|
|
145
|
+
this.reason ??= reason;
|
|
146
|
+
this.onError?.(`${this.face} at ${this.path} is corrupt: ${reason}`);
|
|
147
|
+
}
|
|
148
|
+
get tripped() {
|
|
149
|
+
return this.reason !== undefined;
|
|
150
|
+
}
|
|
151
|
+
/** 每个方法的第一行。文案里带上文件路径与处置 —— 一个运维读到它要知道去看哪个文件。 */
|
|
152
|
+
assertUsable() {
|
|
153
|
+
if (this.reason === undefined)
|
|
154
|
+
return;
|
|
155
|
+
throw new Error(`refusing to use a corrupt ${this.face} (${this.path}): ${this.reason} — ` +
|
|
156
|
+
"the permission-rule lane is fail-closed until a person inspects the file (moving it aside restarts the lane with an empty log)");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `RuleApprovalRecordStore` 的 File 形。
|
|
161
|
+
*
|
|
162
|
+
* CAS 按 `rev`(core 硬条款,理由逐字见 SQL 侧同名类的头注:只比 state 会让批记录的第二个候选上两次
|
|
163
|
+
* 并发重试都以为自己赢了)。
|
|
164
|
+
*/
|
|
165
|
+
export class FileRuleApprovalRecordStore {
|
|
166
|
+
rows = new Map();
|
|
167
|
+
log;
|
|
168
|
+
gate;
|
|
169
|
+
constructor(dir, onError) {
|
|
170
|
+
ensureDir(dir);
|
|
171
|
+
const path = join(dir, "approvals.jsonl");
|
|
172
|
+
this.gate = new CorruptionGate("permission-rule approval log", path, onError);
|
|
173
|
+
// `onCorrupt` = core 对**内部**不可解析行的报告口(崩溃尾它自己丢弃且不报)。不接它就是把一次
|
|
174
|
+
// 「历史被削掉一块」变成静默 —— #191 门② 说的正是这个形。
|
|
175
|
+
for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
|
|
176
|
+
const parsed = ApprovalLineSchema.safeParse(raw);
|
|
177
|
+
if (!parsed.success) {
|
|
178
|
+
// 形不对 = 与「解析不出」同一类事实(版本漂/手改),同样是历史缺了一块 ⇒ 同样 fail-closed。
|
|
179
|
+
this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (parsed.data.op === "discard")
|
|
183
|
+
this.rows.delete(parsed.data.id);
|
|
184
|
+
else
|
|
185
|
+
this.rows.set(parsed.data.record.id, toApprovalRecord(parsed.data.record));
|
|
186
|
+
}
|
|
187
|
+
this.log = new AppendLog(path);
|
|
188
|
+
}
|
|
189
|
+
/** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
|
|
190
|
+
get corrupt() {
|
|
191
|
+
return this.gate.tripped;
|
|
192
|
+
}
|
|
193
|
+
/** 归还本面 eager 持有的日志描述符(束的 `dispose()` 唯一调用点)。`AppendLog` 上没有 finalizer,
|
|
194
|
+
* 不显式关就是一只跟到进程末尾的 fd —— `LocalBackend` 反复开合(热重载/多根)时按次泄漏。
|
|
195
|
+
* 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
|
|
196
|
+
* 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
|
|
197
|
+
close() {
|
|
198
|
+
this.log.close();
|
|
199
|
+
}
|
|
200
|
+
async get(id) {
|
|
201
|
+
this.gate.assertUsable();
|
|
202
|
+
const r = this.rows.get(id);
|
|
203
|
+
// 深拷贝出门:调用方(core 的兑付腿)会就地改它再交回 `cas`,共享同一个对象会让 CAS 比的是
|
|
204
|
+
// 「自己改过的那份」——那等于没有 CAS。
|
|
205
|
+
return r === undefined ? undefined : structuredClone(r);
|
|
206
|
+
}
|
|
207
|
+
async create(record) {
|
|
208
|
+
this.gate.assertUsable();
|
|
209
|
+
if (this.rows.has(record.id))
|
|
210
|
+
throw new Error(`rule approval record ${record.id} already exists`);
|
|
211
|
+
this.log.append({ op: "put", record }, true); // 先盘后索引(顶注)
|
|
212
|
+
this.rows.set(record.id, structuredClone(record));
|
|
213
|
+
}
|
|
214
|
+
async cas(id, expectRev, next) {
|
|
215
|
+
this.gate.assertUsable();
|
|
216
|
+
if (next.rev !== expectRev + 1) {
|
|
217
|
+
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
218
|
+
}
|
|
219
|
+
const cur = this.rows.get(id);
|
|
220
|
+
if (cur === undefined || cur.rev !== expectRev)
|
|
221
|
+
return false;
|
|
222
|
+
this.log.append({ op: "put", record: next }, true);
|
|
223
|
+
this.rows.set(id, structuredClone(next));
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
/** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
|
|
227
|
+
* `state === "pending"` 是硬的:已确认/已兑付的记录是一次真人同意的审计事实。 */
|
|
228
|
+
async discardPendingRecord(recordId) {
|
|
229
|
+
this.gate.assertUsable();
|
|
230
|
+
const cur = this.rows.get(recordId);
|
|
231
|
+
if (cur === undefined || cur.state !== "pending")
|
|
232
|
+
return false;
|
|
233
|
+
this.log.append({ op: "discard", id: recordId }, true);
|
|
234
|
+
this.rows.delete(recordId);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
|
|
240
|
+
* **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
|
|
241
|
+
*/
|
|
242
|
+
export class FileRuleImportTicketStore {
|
|
243
|
+
rows = new Map();
|
|
244
|
+
log;
|
|
245
|
+
gate;
|
|
246
|
+
constructor(dir, now, onError) {
|
|
247
|
+
this.now = now ?? Date.now;
|
|
248
|
+
ensureDir(dir);
|
|
249
|
+
const path = join(dir, "import-tickets.jsonl");
|
|
250
|
+
this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
|
|
251
|
+
// 🔴 这一面是四条信任边界里「一次性原子消费」的**唯一**载体(SQL 形靠引擎行锁,File 形靠这份日志)。
|
|
252
|
+
// 内部坏行 ⇒ 整面 fail-closed:一张票复活成未认领,代价是一次人的同意被重复兑付。
|
|
253
|
+
for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
|
|
254
|
+
const parsed = TicketLineSchema.safeParse(raw);
|
|
255
|
+
if (!parsed.success) {
|
|
256
|
+
this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const line = parsed.data;
|
|
260
|
+
if (line.op === "mint") {
|
|
261
|
+
this.rows.set(line.ticketId, { ownerKey: line.ownerKey, approvalId: line.approvalId, payloadHash: line.payloadHash, expiresAtMs: line.expiresAtMs });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const row = this.rows.get(line.ticketId);
|
|
265
|
+
if (row === undefined)
|
|
266
|
+
continue;
|
|
267
|
+
if (line.op === "consume")
|
|
268
|
+
row.consumedAtMs = line.atMs;
|
|
269
|
+
else
|
|
270
|
+
delete row.consumedAtMs;
|
|
271
|
+
}
|
|
272
|
+
this.log = new AppendLog(path);
|
|
273
|
+
}
|
|
274
|
+
now;
|
|
275
|
+
/** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
|
|
276
|
+
get corrupt() {
|
|
277
|
+
return this.gate.tripped;
|
|
278
|
+
}
|
|
279
|
+
/** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
|
|
280
|
+
close() {
|
|
281
|
+
this.log.close();
|
|
282
|
+
}
|
|
283
|
+
async mint(input) {
|
|
284
|
+
this.gate.assertUsable();
|
|
285
|
+
const expiresAtMs = this.now() + input.ttlMs;
|
|
286
|
+
// 摘要函数与 SQL 形**同一个**(载荷绑定的等式两侧同源;两份实现必然各自漂)。
|
|
287
|
+
const payloadHash = buildRulePayloadHash(input.candidates);
|
|
288
|
+
// owner 键:本车道只铸 principal 形的票(local-owner 桶没有跨网络的导入口)。刻意存**明文
|
|
289
|
+
// principal** 而不是 SQL 侧那把 sha —— 那把 hash 的两条理由(列宽静默截断、local-owner 需要定长
|
|
290
|
+
// 同域表示)在一份进程内 Map 上都不成立,而明文让一次取证直接读得懂。
|
|
291
|
+
this.log.append({ op: "mint", ticketId: input.ticketId, ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs }, true);
|
|
292
|
+
this.rows.set(input.ticketId, { ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs });
|
|
293
|
+
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
294
|
+
}
|
|
295
|
+
/** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
|
|
296
|
+
* 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
|
|
297
|
+
async consume(ticketId, principal) {
|
|
298
|
+
this.gate.assertUsable();
|
|
299
|
+
const row = this.rows.get(ticketId);
|
|
300
|
+
if (row === undefined)
|
|
301
|
+
return { ok: false, reason: "unknown" };
|
|
302
|
+
if (row.ownerKey !== principal)
|
|
303
|
+
return { ok: false, reason: "wrong-principal" };
|
|
304
|
+
if (row.consumedAtMs !== undefined)
|
|
305
|
+
return { ok: false, reason: "consumed" };
|
|
306
|
+
const nowMs = this.now();
|
|
307
|
+
if (row.expiresAtMs <= nowMs)
|
|
308
|
+
return { ok: false, reason: "expired" };
|
|
309
|
+
this.log.append({ op: "consume", ticketId, atMs: nowMs }, true);
|
|
310
|
+
row.consumedAtMs = nowMs;
|
|
311
|
+
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
312
|
+
}
|
|
313
|
+
/** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
|
|
314
|
+
* (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
|
|
315
|
+
async release(ticketId, principal, mustRemainValidMs = 0) {
|
|
316
|
+
this.gate.assertUsable();
|
|
317
|
+
const row = this.rows.get(ticketId);
|
|
318
|
+
if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined)
|
|
319
|
+
return false;
|
|
320
|
+
if (row.expiresAtMs <= this.now() + mustRemainValidMs)
|
|
321
|
+
return false;
|
|
322
|
+
this.log.append({ op: "release", ticketId }, true);
|
|
323
|
+
delete row.consumedAtMs;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* 装配 local 三面束。
|
|
329
|
+
*
|
|
330
|
+
* 🔴 `provider` 必须是**单例**(F1):core 的 `FilePermissionRuleStoreProvider` 在第一次取写面时对规则
|
|
331
|
+
* 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
|
|
332
|
+
* 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
|
|
333
|
+
* (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
|
|
334
|
+
*
|
|
335
|
+
* `onError` 接的是 core 规则文件的**披露面**(读不出来 / 校验和不符 / 撞上符号链接时它答零规则并
|
|
336
|
+
* 说出来)。接住它打一条 warn 是**必须**的:那条路径上「零规则」与「真的没有规则」在读面上同形,
|
|
337
|
+
* 不留痕就变成一次静默的 fail-closed(用户会突然被反复询问,却没有任何线索)。
|
|
338
|
+
*/
|
|
339
|
+
export function createFilePermissionRuleStores(root, opts) {
|
|
340
|
+
const dir = join(root, "permission-rules");
|
|
341
|
+
ensureDir(dir);
|
|
342
|
+
const provider = opts?.onError ? new FilePermissionRuleStoreProvider(dir, opts.onError) : new FilePermissionRuleStoreProvider(dir);
|
|
343
|
+
// 三面共用**同一个**告警口:core 规则文件的披露与本仓两份日志的损坏对运维是同一件事
|
|
344
|
+
// (「这台机器上的规则面出问题了,去看这个目录」),分成两个 sink 只会让其中一个没人接。
|
|
345
|
+
const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
|
|
346
|
+
const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError);
|
|
347
|
+
return {
|
|
348
|
+
provider,
|
|
349
|
+
approvals,
|
|
350
|
+
tickets,
|
|
351
|
+
/** 桶数 = 规则目录里的桶文件数(core 的命名:`<64 hex>.json` = 一只 principal 桶,
|
|
352
|
+
* `local-owner.json` = 身份缺席桶)。**刻意不数**审批/票的日志文件与收编标记文件 ——
|
|
353
|
+
* 审计问的是「有没有既有的规则状态」,不是「这个目录里有几个文件」。
|
|
354
|
+
*
|
|
355
|
+
* 🔴 读不出目录就**抛**(不 catch 成 0)。目录在装配时刚 `ensureDir` 过,读不动只可能是被外力
|
|
356
|
+
* 删掉/权限被改 —— 那是「不知道」,不是「零」。回 0 会被休眠行审计读成「确认没有休眠行」,把一次
|
|
357
|
+
* 读失败伪装成一个结论;审计那侧本来就有 fail-open 臂(warn + 不拒启),让它去处置才是对的分工。 */
|
|
358
|
+
countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
|
|
359
|
+
/** 三面各自的释放,**两只日志先于 provider**:provider 那步释放的是规则目录的进程级写锁,
|
|
360
|
+
* 一旦它先松手,同一个根就可能被下一位持有者开起来 —— 而此刻本束的两只 fd 还开着。顺序写死
|
|
361
|
+
* 在这里,`LocalBackend.close()` 只需调这一只口(与它对其余 file 店逐行关闭的既有纪律同形)。
|
|
362
|
+
* 幂等:两只 `close()` 与 `provider.dispose()` 都可重入(重复调用是常态 —— 兜底路径与
|
|
363
|
+
* `LocalBackend.close()` 会各调一次)。 */
|
|
364
|
+
dispose: () => {
|
|
365
|
+
approvals.close();
|
|
366
|
+
tickets.close();
|
|
367
|
+
provider.dispose();
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
//# sourceMappingURL=permission-rule-store-file.js.map
|
|
@@ -237,6 +237,13 @@ export interface PermissionRuleStores {
|
|
|
237
237
|
provider: SqlPermissionRuleStoreProvider;
|
|
238
238
|
approvals: SqlRuleApprovalRecordStore;
|
|
239
239
|
tickets: SqlRuleImportTicketStore;
|
|
240
|
+
/** #203 §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。
|
|
241
|
+
*
|
|
242
|
+
* 🔴 为什么数**桶**而不是数**规则**:数规则要把每一行的 `rules_json` 都读回来再解析(一只桶的规则集
|
|
243
|
+
* 没有硬上限,顶注 §列宽依据已说明它是 LONGTEXT),那是一次无界的 boot 期扫描 —— 为一条诊断行付这个
|
|
244
|
+
* 代价不划算。桶数回答的正是审计要问的那个问题(「这台部署上有没有既有的规则状态」),而且是一次
|
|
245
|
+
* 索引级 `COUNT(*)`。消费点(`boot/permission-rules-audit.ts`)的文案因此逐字说的是 bucket,不是 rule。 */
|
|
246
|
+
countBuckets(): Promise<number>;
|
|
240
247
|
}
|
|
241
248
|
export declare function createSqlPermissionRuleStores(db: SqlDriver, now?: () => number): PermissionRuleStores;
|
|
242
249
|
//# sourceMappingURL=permission-rule-store-sql.d.ts.map
|
|
@@ -812,6 +812,17 @@ export function createSqlPermissionRuleStores(db, now) {
|
|
|
812
812
|
provider: new SqlPermissionRuleStoreProvider(db, now),
|
|
813
813
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
814
814
|
tickets: new SqlRuleImportTicketStore(db, now),
|
|
815
|
+
// 两个方言逐字同形(`COUNT(*)` 无方言差),所以刻意**不**走 `q(tidb, pg)` 的双串姿势 —— 那会造出
|
|
816
|
+
// 两份可以各自漂的同一句 SQL。参数空数组:本语句没有绑定位。
|
|
817
|
+
countBuckets: async () => {
|
|
818
|
+
const { rows } = await db.query(`SELECT COUNT(*) AS n FROM ${PERMISSION_RULE_TABLE}`, []);
|
|
819
|
+
// PG 的 `COUNT(*)` 走 bigint ⇒ 驱动交回**字符串**;mysql2 交回 number。`Number()` 是两侧共同的收口,
|
|
820
|
+
// 读不出数(列缺席/NaN)⇒ 响亮抛,绝不静默当 0(0 会被审计读成「确认没有休眠行」= 假结论)。
|
|
821
|
+
const n = Number(rows[0]?.["n"] ?? Number.NaN);
|
|
822
|
+
if (!Number.isFinite(n))
|
|
823
|
+
throw new Error(`${PERMISSION_RULE_TABLE} bucket count came back unreadable (${String(rows[0]?.["n"])})`);
|
|
824
|
+
return n;
|
|
825
|
+
},
|
|
815
826
|
};
|
|
816
827
|
}
|
|
817
828
|
//# sourceMappingURL=permission-rule-store-sql.js.map
|
|
@@ -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,29 @@ 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
225
|
this.fileBackend = new FileStorageBackend({ root, ...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}) }); // REWIND_SNAPSHOT_MAX_MB
|
|
210
226
|
}
|
|
211
227
|
catch (e) {
|
|
228
|
+
if (e instanceof AdoptionError)
|
|
229
|
+
throw e;
|
|
212
230
|
throw new Error(`DB_BACKEND=local cannot open data dir ${root}: ${e instanceof Error ? e.message : String(e)} ` +
|
|
213
231
|
"(another HTTP service / run-local instance may own it; stop it first or set LOCAL_DATA_ROOT to a free dir)");
|
|
214
232
|
}
|
|
@@ -235,6 +253,10 @@ class LocalBackend {
|
|
|
235
253
|
this.sendFileLedgerStore.dispose(); // release the sendfile-ledger append fd (lazy — may never have opened)
|
|
236
254
|
this.workflowJournalStore.dispose(); // release the per-run journal-ledger fds
|
|
237
255
|
this.checkpointStoreInst?.close(); // release the checkpoint ledger fd (lazy, may never have been built)
|
|
256
|
+
// #203 §1: release core's permission-rule DIRECTORY write lock (lazy — may never have been built).
|
|
257
|
+
// Same discipline as root/LOCK below: an un-released writer lock tells the next boot that a live process
|
|
258
|
+
// owns the rule directory, and core refuses the second holder LOUDLY rather than queueing.
|
|
259
|
+
this.permissionRuleStoresInst?.dispose();
|
|
238
260
|
await this.fileBackend.dispose(); // MUST release root/LOCK so a graceful restart can re-acquire the data dir
|
|
239
261
|
}
|
|
240
262
|
run() { return this.runStore; }
|
|
@@ -244,9 +266,22 @@ class LocalBackend {
|
|
|
244
266
|
* new 一个就等于每个消费者各拿一份互不可见的账(而 SQL twin 天然共享一张表)。易失代价 +
|
|
245
267
|
* 「local 车道默认不上协议」的裁定见 StoreBackend.approvalAsk() 的接口注。 */
|
|
246
268
|
approvalAsk() { return this.approvalAskStore; }
|
|
247
|
-
/** #
|
|
248
|
-
*
|
|
249
|
-
|
|
269
|
+
/** #203 §1:local 车道的 File 三面束。**必须**是 singleton —— core 的 File provider 在第一次取写面时
|
|
270
|
+
* 对规则目录取一把进程级写锁,第二个持有者被**响亮拒绝**(不是排队),所以每次 `new` 一个就等于给
|
|
271
|
+
* 自己造一次「另一个进程占着这个目录」的假象。`close()` 里 `dispose()` 释放它(与数据根 `root/LOCK`
|
|
272
|
+
* 同一条纪律:不释放 ⇒ 优雅重启被自己上一条命留下的锁挡在门外)。惰性建:没开旋钮的部署不该因为
|
|
273
|
+
* 一个从不使用的面而在数据根里长出目录、更不该白占一把写锁。 */
|
|
274
|
+
permissionRule() {
|
|
275
|
+
if (!this.permissionRuleStoresInst) {
|
|
276
|
+
this.permissionRuleStoresInst = createFilePermissionRuleStores(this.fileBackend.root, {
|
|
277
|
+
// core 规则文件的披露面(读不出 / 校验和不符 / 撞上符号链接 ⇒ 答零规则**并说出来**)。
|
|
278
|
+
// 不接住它就是一次静默的 fail-closed:用户突然被反复询问,而日志里一个字都没有。
|
|
279
|
+
onError: (message) => this.onWarn?.("permission_rule_store_disclosure", { message }),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return this.permissionRuleStoresInst;
|
|
283
|
+
}
|
|
284
|
+
permissionRuleStoresInst;
|
|
250
285
|
sendFileLedger() { return this.sendFileLedgerStore; } // singleton — append fd lives in the instance
|
|
251
286
|
/** Durable HITL / plan-review parking on the LOCAL lane. Lazy singleton (the inner core store holds
|
|
252
287
|
* an append fd). taskId join ← FileRunStore; E21 owner guard ← LocalSessionStore.ownerOf (same root). */
|
|
@@ -302,10 +337,14 @@ export function counterStoreLabel(store, kind) {
|
|
|
302
337
|
return store ? `shared(${kind ?? "unknown"})` : "in-process";
|
|
303
338
|
}
|
|
304
339
|
/** 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
|
-
|
|
340
|
+
* reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
|
|
341
|
+
*
|
|
342
|
+
* `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
|
|
343
|
+
* permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
|
|
344
|
+
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one. */
|
|
345
|
+
export function createStoreBackend(config, onWarn) {
|
|
307
346
|
if (config.dbBackend === "local")
|
|
308
|
-
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config);
|
|
347
|
+
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn);
|
|
309
348
|
if (config.dbBackend === "pg") {
|
|
310
349
|
if (!config.pg)
|
|
311
350
|
return undefined;
|
|
@@ -335,12 +374,22 @@ export async function openStoreBackendWithFallback(config, logger) {
|
|
|
335
374
|
const mayFallback = config.dbBackend === "local" ? !config.dbBackendExplicit : config.sessionBackend === "auto";
|
|
336
375
|
let backend;
|
|
337
376
|
try {
|
|
338
|
-
|
|
377
|
+
// 包一层箭头而不是直接传 `logger.warn`:后者会把方法从它的接收者上摘下来,一个带 `this` 的
|
|
378
|
+
// logger 实现会在第一次真披露时炸——而那一刻正是最不该炸的时候(本仓 fail-loud 病族先例)。
|
|
379
|
+
backend = createStoreBackend(config, (msg, meta) => logger.warn(msg, meta));
|
|
339
380
|
// connects + creates schema (also the reachability probe).
|
|
340
381
|
if (backend)
|
|
341
382
|
await backend.ensureSchema();
|
|
342
383
|
}
|
|
343
384
|
catch (err) {
|
|
385
|
+
// 🔴 core 5.23.0([3372] 提货批,codex 复审 high 抓获、验真后修):**I6 adoption 门不许被降级臂吃掉**。
|
|
386
|
+
// 1.292 拍的降级裁定说的是「只读 FS / mkdir 失败」那一类——它们的意思是「这台机器存不了盘」,降级到
|
|
387
|
+
// 内存是诚实的等价物。`AdoptionError` 说的是完全不同的一句话:「这份数据**有主、正在搬**」(design/183
|
|
388
|
+
// I6:数据根的 `adoption.json` 在飞,或标记读不出)。对后者降级 = **在搬家途中另开一个写者**,而那正是
|
|
389
|
+
// I6 门存在的全部理由;更坏的是它还会留下一行「DB 不可达」的 warn,把运维支去查连通性。
|
|
390
|
+
// ⇒ 与显式 DB_BACKEND 同待遇:无条件 fail-loud,穿过 `mayFallback`。
|
|
391
|
+
if (err instanceof AdoptionError)
|
|
392
|
+
throw err;
|
|
344
393
|
if (!mayFallback)
|
|
345
394
|
throw err;
|
|
346
395
|
// S5 (SILENT-FALLBACK P0-b): one boot warn was the ONLY trace of "this replica is not persisting".
|