@sema-agent/server 7.30.0-rc.1 → 7.31.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/session-faces.js +31 -1
- package/dist/http/route-ctx.d.ts +6 -1
- package/dist/http/routes/approvals-assistant.js +14 -2
- package/dist/http/routes/sessions-list.js +8 -4
- package/dist/http/routes/sessions.js +11 -5
- package/dist/http/server.js +44 -3
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/caching-session-store.d.ts +1 -0
- package/dist/plugins/file-run-store.d.ts +11 -3
- package/dist/plugins/file-run-store.js +28 -7
- package/dist/plugins/local-session-store.d.ts +2 -0
- package/dist/plugins/local-session-store.js +2 -2
- package/dist/plugins/memory-run-store.d.ts +13 -2
- package/dist/plugins/memory-run-store.js +17 -5
- package/dist/plugins/permission-rule-store-file.d.ts +3 -3
- package/dist/plugins/permission-rule-store-file.js +8 -7
- package/dist/plugins/permission-rule-store-sql.d.ts +24 -2
- package/dist/plugins/permission-rule-store-sql.js +29 -7
- package/dist/plugins/pg-session-storage.d.ts +1 -0
- package/dist/plugins/pg-session-storage.js +2 -1
- package/dist/plugins/retention-store-sql.d.ts +19 -6
- package/dist/plugins/retention-store-sql.js +23 -13
- package/dist/plugins/run-store-sql.d.ts +31 -7
- package/dist/plugins/run-store-sql.js +42 -13
- package/dist/plugins/store-backend.d.ts +9 -2
- package/dist/plugins/store-backend.js +30 -7
- package/dist/plugins/tidb-session-store.d.ts +1 -0
- package/dist/plugins/tidb-session-store.js +3 -1
- package/dist/rules-consent.d.ts +7 -4
- package/dist/rules-consent.js +77 -2
- package/dist/security.d.ts +52 -1
- package/dist/security.js +51 -0
- package/dist/wall-clock-jump-guard.d.ts +91 -0
- package/dist/wall-clock-jump-guard.js +118 -0
- package/package.json +1 -1
- package/dist/boot/lexical-path-env.d.ts +0 -10
- package/dist/boot/lexical-path-env.js +0 -88
- package/dist/capabilities/oa-tools.d.ts +0 -15
- package/dist/capabilities/oa-tools.js +0 -54
- package/dist/finance/cost-taxonomy.d.ts +0 -34
- package/dist/finance/cost-taxonomy.js +0 -26
- package/dist/plugins/approval-store-sql.d.ts +0 -116
- package/dist/plugins/approval-store-sql.js +0 -151
- package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
- package/dist/plugins/file-workflow-journal-store.js +0 -12
- package/dist/plugins/pg-approval-store.d.ts +0 -9
- package/dist/plugins/pg-approval-store.js +0 -9
- package/dist/plugins/pg-breaker-state.d.ts +0 -8
- package/dist/plugins/pg-breaker-state.js +0 -8
- package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
- package/dist/plugins/pg-checkpoint-store.js +0 -10
- package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/pg-file-snapshot-store.js +0 -8
- package/dist/plugins/pg-image-bake.d.ts +0 -12
- package/dist/plugins/pg-image-bake.js +0 -11
- package/dist/plugins/pg-image-index.d.ts +0 -12
- package/dist/plugins/pg-image-index.js +0 -11
- package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
- package/dist/plugins/pg-outcome-ledger.js +0 -11
- package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/pg-resume-anchor-store.js +0 -7
- package/dist/plugins/pg-run-store.d.ts +0 -9
- package/dist/plugins/pg-run-store.js +0 -9
- package/dist/plugins/pg-session-policy-store.d.ts +0 -7
- package/dist/plugins/pg-session-policy-store.js +0 -7
- package/dist/plugins/pg-session-store.d.ts +0 -12
- package/dist/plugins/pg-session-store.js +0 -12
- package/dist/plugins/pg-tool-result-store.d.ts +0 -9
- package/dist/plugins/pg-tool-result-store.js +0 -9
- package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-journal-store.js +0 -9
- package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-run-store.js +0 -9
- package/dist/plugins/tidb-approval-store.d.ts +0 -8
- package/dist/plugins/tidb-approval-store.js +0 -8
- package/dist/plugins/tidb-breaker-state.d.ts +0 -7
- package/dist/plugins/tidb-breaker-state.js +0 -7
- package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
- package/dist/plugins/tidb-checkpoint-store.js +0 -9
- package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/tidb-file-snapshot-store.js +0 -8
- package/dist/plugins/tidb-image-bake.d.ts +0 -12
- package/dist/plugins/tidb-image-bake.js +0 -11
- package/dist/plugins/tidb-image-index.d.ts +0 -12
- package/dist/plugins/tidb-image-index.js +0 -11
- package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
- package/dist/plugins/tidb-outcome-ledger.js +0 -12
- package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/tidb-resume-anchor-store.js +0 -7
- package/dist/plugins/tidb-run-store.d.ts +0 -10
- package/dist/plugins/tidb-run-store.js +0 -9
- package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
- package/dist/plugins/tidb-session-policy-store.js +0 -7
- package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
- package/dist/plugins/tidb-tool-result-store.js +0 -10
- package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/tidb-workflow-journal-store.js +0 -9
- package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
- package/dist/plugins/tidb-workflow-run-store.js +0 -10
- package/dist/plugins/workflow-journal-limits.d.ts +0 -12
- package/dist/plugins/workflow-journal-limits.js +0 -12
- package/dist/sema-registry.d.ts +0 -41
- package/dist/sema-registry.js +0 -40
package/dist/rules-consent.js
CHANGED
|
@@ -127,6 +127,63 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
127
127
|
const deps = { provider: stores.provider, approvals: stores.approvals };
|
|
128
128
|
const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
|
|
129
129
|
const newTicketId = opts?.newTicketId ?? (() => randomUUID());
|
|
130
|
+
const monotonic = opts?.monotonic ?? (() => performance.now());
|
|
131
|
+
/**
|
|
132
|
+
* [4232]③/[4235]③ —— **本进程铸出的票**的单调锚台账。
|
|
133
|
+
*
|
|
134
|
+
* 病灶(test 真机两条读数):`expiresAtMs = 铸票时刻的墙钟 + TTL`。墙钟在铸票与兑付之间被拨动 ⇒
|
|
135
|
+
* ·(前拨)真实只过 3 秒的票被判过期;
|
|
136
|
+
* ·(铸票时钟本身慢了 11 分钟,随后被 NTP 纠正)票**出生即过期** —— `expiresAtMs` 换算出的时刻
|
|
137
|
+
* 比这张票真实铸出的时刻还早一分钟。
|
|
138
|
+
* core [4235]③ 的判词「从票据内部无解」是对的:`mintedAt` 与 `expiresAt` 同源同错,行里没有第二个基准。
|
|
139
|
+
* 但**铸票与兑付发生在同一个进程**时,进程自己有第二个基准 —— 单调钟。于是判定钟改成
|
|
140
|
+
* `判定 now = 铸票时刻的墙钟 + (单调钟现在 − 单调钟铸票时)`
|
|
141
|
+
* 即「签发与判定同源单调锚」。它**给不了票额外寿命**:投影严格按单调钟前进,真实走满 TTL 就必然越过
|
|
142
|
+
* `expiresAtMs`(负控钉在 db-integration 里)。记账列(`consumed_at_ms`)一律仍是真墙钟。
|
|
143
|
+
*
|
|
144
|
+
* 🔴 **射程边界 —— 未闭合的残余,如实登记**(codex 对抗复审 R1 [high] 四,验真后**接受**):本锚只活在
|
|
145
|
+
* 当前进程的 Map 里。prepare 与 redeem 被负载均衡分到**不同副本**时(不需要故障,常规轮询就够),
|
|
146
|
+
* `decisionClock` 返回 undefined、store 退回该副本的墙钟 —— 那台副本若有钟偏,有效票**仍会**出生即过期。
|
|
147
|
+
* 也就是说本修把缺陷从「所有兑付」缩小到「跨副本兑付」,**没有消灭它**;正确性因此隐含依赖了一条
|
|
148
|
+
* 没人声明过的会话粘滞。不要把这条注读成「已解」。
|
|
149
|
+
*
|
|
150
|
+
* 收口方向(下一批,不在本小件批射程):把判定权威换成**两副本共享的那口钟 —— 数据库自己的钟**:
|
|
151
|
+
* 铸票时 `expires_at_ms = <db now> + ttl`、兑付时 `WHERE expires_at_ms > <db now>`
|
|
152
|
+
* (MySQL `CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED)` / PG `(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint`)。
|
|
153
|
+
* 那一形对**所有**副本同时成立,还能把本台账整个删掉。记账列仍走进程墙钟(两钟分家的语义不变)。
|
|
154
|
+
*/
|
|
155
|
+
const anchors = new Map();
|
|
156
|
+
/** 台账只服务「本进程铸、本进程兑」的窗,所以按单调钟自清:超过一个 TTL 的锚已经不可能改变任何判定。
|
|
157
|
+
* 另加一个硬帽,免得一条只 prepare 不 redeem 的路径把 Map 撑大(每条锚两个 number,帽住即够)。 */
|
|
158
|
+
const ANCHOR_CAP = 4096;
|
|
159
|
+
const pruneAnchors = (nowMono) => {
|
|
160
|
+
for (const [id, a] of anchors) {
|
|
161
|
+
if (nowMono - a.mintedAtMono > ticketTtlMs)
|
|
162
|
+
anchors.delete(id);
|
|
163
|
+
}
|
|
164
|
+
if (anchors.size <= ANCHOR_CAP)
|
|
165
|
+
return;
|
|
166
|
+
// 仍然超帽 ⇒ 丢最老的(Map 迭代序 = 插入序 = 铸票序)。丢掉只是退回墙钟判定,不产生错判方向。
|
|
167
|
+
const excess = anchors.size - ANCHOR_CAP;
|
|
168
|
+
let dropped = 0;
|
|
169
|
+
for (const id of anchors.keys()) {
|
|
170
|
+
if (dropped++ >= excess)
|
|
171
|
+
break;
|
|
172
|
+
anchors.delete(id);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* 本进程铸的票 ⇒ 单调投影;别处铸的 ⇒ `undefined`(store 退回墙钟,既有行为逐字不变)。
|
|
177
|
+
*
|
|
178
|
+
* 🔴 `Math.ceil` 不是装饰:①`performance.now()` 是**小数**毫秒,而 `expires_at_ms` 是 BIGINT 列 ——
|
|
179
|
+
* 小数直接绑进 PG 会 `invalid input syntax for type bigint`(真双库当场逮到,mysql2 那侧则会静默接受,
|
|
180
|
+
* 正是「一个方言绿一个方言红」的经典形);②取整方向必须**保守**:判定式是 `expires_at_ms > 判定 now`,
|
|
181
|
+
* 判定 now 取小 = 票更晚过期,所以向上取整才不会白送半毫秒寿命。
|
|
182
|
+
*/
|
|
183
|
+
const decisionClock = (ticketId) => {
|
|
184
|
+
const a = anchors.get(ticketId);
|
|
185
|
+
return a === undefined ? undefined : { decisionNowMs: Math.ceil(a.mintedAtWall + (monotonic() - a.mintedAtMono)) };
|
|
186
|
+
};
|
|
130
187
|
return {
|
|
131
188
|
async persistCardRule(input) {
|
|
132
189
|
// 🔴 候选**由引擎铸**(core `prepareCardApproval` 不收调用方候选;那条入口上「写自己的规则」
|
|
@@ -179,12 +236,19 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
179
236
|
}
|
|
180
237
|
return { ok: false, reason: "too-many-candidates", candidates: preview.candidates.length };
|
|
181
238
|
}
|
|
239
|
+
// [4232]③:铸票**当拍**同时钉下两只钟 —— 墙钟锚取 `minted.expiresAtMs - ttlMs`(= store 自己用的
|
|
240
|
+
// 那个铸票时刻,不另取一次 `Date.now()`:两处各取一次会让锚与行差出一跳的量),单调锚取本进程单调钟。
|
|
241
|
+
const mintedAtMono = monotonic();
|
|
182
242
|
const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, candidates: preview.candidates, ttlMs: ticketTtlMs });
|
|
243
|
+
pruneAnchors(mintedAtMono);
|
|
244
|
+
anchors.set(minted.ticketId, { mintedAtWall: minted.expiresAtMs - ticketTtlMs, mintedAtMono });
|
|
183
245
|
return { ok: true, preview, ticket: minted.ticketId, expiresAtMs: minted.expiresAtMs };
|
|
184
246
|
},
|
|
185
247
|
async redeemImport(principal, ticket) {
|
|
186
248
|
// ①②③ 一次原子**认领**同时回答(principal 绑定 / 未过期 / 未认领);四类拒绝在 wire 面同形。
|
|
187
|
-
|
|
249
|
+
// [4232]③:判定钟走本进程的单调锚(本进程没铸过这张票 ⇒ undefined ⇒ store 退回墙钟)。
|
|
250
|
+
const clock = decisionClock(ticket);
|
|
251
|
+
const consumed = await stores.tickets.consume(ticket, principal, clock);
|
|
188
252
|
if (!consumed.ok)
|
|
189
253
|
return { ok: false, reason: "ticket-unusable", detail: consumed.reason };
|
|
190
254
|
// 🔴 codex round1 [high] 一(验真后修):认领**之后**的每一步都可能失败,而旧形把认领当终局 ⇒
|
|
@@ -197,7 +261,18 @@ export function createRuleConsentLane(stores, opts) {
|
|
|
197
261
|
// 判据必须是「释放**确认**成功」;没确认就不许承诺重试(如实按不可重试报,四类同形 404)。
|
|
198
262
|
const releaseClaim = async () => {
|
|
199
263
|
try {
|
|
200
|
-
|
|
264
|
+
// 🔴 判定钟**当场重取**,不复用 `consume` 那次的读数(F-B,2026-08-17 合并码扫描)。同锚(同一张票的
|
|
265
|
+
// `mintedAtWall`/`mintedAtMono`)、同尺(同一只单调钟),但**读数必须是现在**:
|
|
266
|
+
// · `consume` 问的是向后看的问题(此刻窗还开着吗);`release` 问的是向前看的问题(这张票能不能
|
|
267
|
+
// 活过我即将通告的 Retry-After)。第二问只有在**释放这一刻**问才有意义。
|
|
268
|
+
// · capture 与这里之间隔着 consume 两往返 + approvals.get + confirmRuleApproval 的 CAS/OCC +
|
|
269
|
+
// 最多 MAX_IMPORT_CANDIDATES 条候选的串行兑付循环,而本闭包**只在店退化时**才被走到 ——
|
|
270
|
+
// 缺陷发作时这段恰恰最长(秒级 vs 2s 的窗),旧注那句「重取一次投影只会多走几微秒」说的是
|
|
271
|
+
// 重取的**成本**,却被当成了流逝量的上界,那是两件事。
|
|
272
|
+
// · 单调钟不回退 ⇒ 冻结读数恒 ≤ 新读数 ⇒ 误差全部落在 **permissive** 一侧,而 store 侧 `release`
|
|
273
|
+
// 的头注把这道门定义成保守门(「承诺一次必然兑现不了的重试比不承诺更坏」)。
|
|
274
|
+
// 本进程没铸过这张票时两处都回 undefined(store 退回墙钟),语义逐字不变。
|
|
275
|
+
return await stores.tickets.release(ticket, principal, RULE_IMPORT_RETRY_AFTER_SEC * 1000, decisionClock(ticket));
|
|
201
276
|
}
|
|
202
277
|
catch (err) {
|
|
203
278
|
// 放不回去 = 回到「认领即烧票」的旧形(属主这一轮不能重试,票随 TTL 消失)。**不**把一次
|
package/dist/security.d.ts
CHANGED
|
@@ -38,12 +38,63 @@ export interface SessionListItem {
|
|
|
38
38
|
* 不含读面,也不含 `POST /v1/tasks` 那条提交腿 —— 它有自己的同族门(别重复拦,双拦会让错误归因变糊)。
|
|
39
39
|
*/
|
|
40
40
|
export declare function isDestructiveSessionWrite(method: string, url: string): boolean;
|
|
41
|
+
/** [4198] 窄互认第二臂的租户键字面量 —— 单机形下 core 写侧 `spec.principal ?? "default"` 铸出的那一个,
|
|
42
|
+
* 也是壳 [4195]G1 翻面后随请求带的那一个。**不是哨兵**([4183] 措辞):它是单机形的真租户键。 */
|
|
43
|
+
export declare const LEGACY_UNOWNED_AMNESTY_TENANT = "default";
|
|
44
|
+
/**
|
|
45
|
+
* [4198] 窄互认**第二臂**(clay 裁 A / [4225] 下达;cli [4238] 表态即发)—— `legacyShellOwner`
|
|
46
|
+
* (本文件 createAuthorizer 内 + http/server.ts `runOwnerOk`)的对称孪生。
|
|
47
|
+
*
|
|
48
|
+
* 病灶:7.8.0 第一臂承接的是 `owner === "anon:shell-live"` 哨兵行;此后壳把无主表示法归一为
|
|
49
|
+
* **缺席**,于是 1.0.82 及更早留下的存量会话 owner 列是 **null**。壳 1.0.83 起随请求带
|
|
50
|
+
* `default` 主([4195] G1 闸口),`null !== "default"` ⇒ 存量会话在 session 面
|
|
51
|
+
* **列表不出现 / fork 404 / delete 静默 {deleted:false} / workspace 404**(三面矩阵 [4198] 面3;
|
|
52
|
+
* fleet 面与 run 读面天然连续,面1/面2 零施工)。
|
|
53
|
+
*
|
|
54
|
+
* 🔒 **三条件缺一不放行**(与第一臂同源,不是兼容臂):
|
|
55
|
+
* ① 单机形 `!requirePrincipal` —— 多租部署本臂**死路**(header 必到、键必具名);
|
|
56
|
+
* ② 存量行恰为无主 `owner === null` —— 任何具名 owner 的行不受影响;
|
|
57
|
+
* ③ 调用方恰为 `default` 主 —— 非 default 的具名主对无主行仍走原门(404 / deleted:false)。
|
|
58
|
+
* 反向风险有界:①限定的单机形本就单用户,把无主行判给 default 主不产生跨租泄露。
|
|
59
|
+
*
|
|
60
|
+
* 🪦 墓碑注(与第一臂同源):这是两个表示法之间的**过渡承接**。存量随会话生命周期消亡后,本臂
|
|
61
|
+
* 连同 {@link LEGACY_UNOWNED_AMNESTY_TENANT} 与几处调用点可**整删**,不留兼容层。
|
|
62
|
+
*
|
|
63
|
+
* ── 逐面判过、**刻意不放行**的三处(免得后人当遗漏再补一次)────────────────────────────────
|
|
64
|
+
* · `runOwnerOk` 三读面(head / audit 单读 / events):`owner === null` 那一支**本来就放行**
|
|
65
|
+
* (判据是 `owner !== null && principal !== owner`),不需要本臂。[4198] 帖文里「单读 404」一句
|
|
66
|
+
* 经亲读实现行**不成立**,已加现状钉(session-head-probe.test.ts)锁住这个事实。
|
|
67
|
+
* · `PUT /v1/sessions/:id/policy` 的 `owner === null ⇒ 409 conflict.session_ownerless`:那是**有意的
|
|
68
|
+
* fail-closed** —— 无主会话没有稳定的 run-principal,存下去的规则读不到、静默失效。放宽它等于制造
|
|
69
|
+
* 一条 fail-open,不在本臂辖域。
|
|
70
|
+
* · `…/sync/*`(PULL/PUSH/import):PULL 虽是只读、形同 workspace,但这一族**带一条破坏性 import 腿**
|
|
71
|
+
* (`isDestructiveSessionWrite` 明列),而 [4198]/[4225] 裁的是会话**读面与生命周期**四门。放宽一个
|
|
72
|
+
* 写面族超出裁定射程 —— 要做另立一次表态,不在本臂里顺手带过。
|
|
73
|
+
*
|
|
74
|
+
* @param requirePrincipal `config.requirePrincipal`(条件①)
|
|
75
|
+
* @param gateOwner `sessionOwnerScope(ForWrite)` 判出的**已验证**主(条件③;fleet-wide 面不经本臂)
|
|
76
|
+
* @param owner 存量行的 owner 列(条件②)
|
|
77
|
+
*/
|
|
78
|
+
export declare function legacyUnownedSessionOk(requirePrincipal: boolean, gateOwner: string | null, owner: string | null): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* {@link legacyUnownedSessionOk} 的**条件①③**半支 —— 只判「这个部署形 × 这个调用方」是否落在窄臂辖域内。
|
|
81
|
+
*
|
|
82
|
+
* 🔴 单独存在的唯一理由是**列表面**:`GET /v1/sessions` 的第三条件(`owner === null`)不在 JS 里判,
|
|
83
|
+
* 它是 store 侧 `owner = ? OR owner IS NULL` 这一句 SQL 谓词 —— 三条件仍然齐,只是第三条件由 SQL 承载
|
|
84
|
+
* (`listSessions({ owner, includeUnowned })` 的 `includeUnowned` 位即本函数的返回值)。
|
|
85
|
+
* 单实体读/写点(fork/delete/workspace)**一律**用三条件齐的 {@link legacyUnownedSessionOk},不要用本函数。
|
|
86
|
+
*/
|
|
87
|
+
export declare function legacyUnownedAmnestyOpen(requirePrincipal: boolean, gateOwner: string | null): boolean;
|
|
41
88
|
export type OwnerAwareSessionStore = SessionStore & {
|
|
42
89
|
ownerOf?: (sessionId: string) => Promise<string | null | undefined>;
|
|
43
90
|
register?: (sessionId: string, owner: string | null) => Promise<void>;
|
|
44
|
-
/** E16 — enumerate the caller's sessions (owner-filtered on the session store's OWN owner column).
|
|
91
|
+
/** E16 — enumerate the caller's sessions (owner-filtered on the session store's OWN owner column).
|
|
92
|
+
* `includeUnowned` = [4198] 窄互认第二臂的**第三条件**(`owner IS NULL`)的 SQL 承载位:置真时
|
|
93
|
+
* 谓词放宽为 `owner = ? OR owner IS NULL`。只在 `owner` 在场时有意义(路由已判①③);`owner` 缺席
|
|
94
|
+
* (fleet-wide)时本位无效——那一面本来就不钉 owner。 */
|
|
45
95
|
listSessions?: (opts: {
|
|
46
96
|
owner?: string;
|
|
97
|
+
includeUnowned?: boolean;
|
|
47
98
|
cursor?: {
|
|
48
99
|
lastActivityAt: string;
|
|
49
100
|
sessionId: string;
|
package/dist/security.js
CHANGED
|
@@ -37,6 +37,57 @@ export function isDestructiveSessionWrite(method, url) {
|
|
|
37
37
|
return /^\/v1\/sessions\/[^/]+\/(sync\/import|fork)$/.test(url);
|
|
38
38
|
return false;
|
|
39
39
|
}
|
|
40
|
+
/** [4198] 窄互认第二臂的租户键字面量 —— 单机形下 core 写侧 `spec.principal ?? "default"` 铸出的那一个,
|
|
41
|
+
* 也是壳 [4195]G1 翻面后随请求带的那一个。**不是哨兵**([4183] 措辞):它是单机形的真租户键。 */
|
|
42
|
+
export const LEGACY_UNOWNED_AMNESTY_TENANT = "default";
|
|
43
|
+
/**
|
|
44
|
+
* [4198] 窄互认**第二臂**(clay 裁 A / [4225] 下达;cli [4238] 表态即发)—— `legacyShellOwner`
|
|
45
|
+
* (本文件 createAuthorizer 内 + http/server.ts `runOwnerOk`)的对称孪生。
|
|
46
|
+
*
|
|
47
|
+
* 病灶:7.8.0 第一臂承接的是 `owner === "anon:shell-live"` 哨兵行;此后壳把无主表示法归一为
|
|
48
|
+
* **缺席**,于是 1.0.82 及更早留下的存量会话 owner 列是 **null**。壳 1.0.83 起随请求带
|
|
49
|
+
* `default` 主([4195] G1 闸口),`null !== "default"` ⇒ 存量会话在 session 面
|
|
50
|
+
* **列表不出现 / fork 404 / delete 静默 {deleted:false} / workspace 404**(三面矩阵 [4198] 面3;
|
|
51
|
+
* fleet 面与 run 读面天然连续,面1/面2 零施工)。
|
|
52
|
+
*
|
|
53
|
+
* 🔒 **三条件缺一不放行**(与第一臂同源,不是兼容臂):
|
|
54
|
+
* ① 单机形 `!requirePrincipal` —— 多租部署本臂**死路**(header 必到、键必具名);
|
|
55
|
+
* ② 存量行恰为无主 `owner === null` —— 任何具名 owner 的行不受影响;
|
|
56
|
+
* ③ 调用方恰为 `default` 主 —— 非 default 的具名主对无主行仍走原门(404 / deleted:false)。
|
|
57
|
+
* 反向风险有界:①限定的单机形本就单用户,把无主行判给 default 主不产生跨租泄露。
|
|
58
|
+
*
|
|
59
|
+
* 🪦 墓碑注(与第一臂同源):这是两个表示法之间的**过渡承接**。存量随会话生命周期消亡后,本臂
|
|
60
|
+
* 连同 {@link LEGACY_UNOWNED_AMNESTY_TENANT} 与几处调用点可**整删**,不留兼容层。
|
|
61
|
+
*
|
|
62
|
+
* ── 逐面判过、**刻意不放行**的三处(免得后人当遗漏再补一次)────────────────────────────────
|
|
63
|
+
* · `runOwnerOk` 三读面(head / audit 单读 / events):`owner === null` 那一支**本来就放行**
|
|
64
|
+
* (判据是 `owner !== null && principal !== owner`),不需要本臂。[4198] 帖文里「单读 404」一句
|
|
65
|
+
* 经亲读实现行**不成立**,已加现状钉(session-head-probe.test.ts)锁住这个事实。
|
|
66
|
+
* · `PUT /v1/sessions/:id/policy` 的 `owner === null ⇒ 409 conflict.session_ownerless`:那是**有意的
|
|
67
|
+
* fail-closed** —— 无主会话没有稳定的 run-principal,存下去的规则读不到、静默失效。放宽它等于制造
|
|
68
|
+
* 一条 fail-open,不在本臂辖域。
|
|
69
|
+
* · `…/sync/*`(PULL/PUSH/import):PULL 虽是只读、形同 workspace,但这一族**带一条破坏性 import 腿**
|
|
70
|
+
* (`isDestructiveSessionWrite` 明列),而 [4198]/[4225] 裁的是会话**读面与生命周期**四门。放宽一个
|
|
71
|
+
* 写面族超出裁定射程 —— 要做另立一次表态,不在本臂里顺手带过。
|
|
72
|
+
*
|
|
73
|
+
* @param requirePrincipal `config.requirePrincipal`(条件①)
|
|
74
|
+
* @param gateOwner `sessionOwnerScope(ForWrite)` 判出的**已验证**主(条件③;fleet-wide 面不经本臂)
|
|
75
|
+
* @param owner 存量行的 owner 列(条件②)
|
|
76
|
+
*/
|
|
77
|
+
export function legacyUnownedSessionOk(requirePrincipal, gateOwner, owner) {
|
|
78
|
+
return owner === null && legacyUnownedAmnestyOpen(requirePrincipal, gateOwner);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* {@link legacyUnownedSessionOk} 的**条件①③**半支 —— 只判「这个部署形 × 这个调用方」是否落在窄臂辖域内。
|
|
82
|
+
*
|
|
83
|
+
* 🔴 单独存在的唯一理由是**列表面**:`GET /v1/sessions` 的第三条件(`owner === null`)不在 JS 里判,
|
|
84
|
+
* 它是 store 侧 `owner = ? OR owner IS NULL` 这一句 SQL 谓词 —— 三条件仍然齐,只是第三条件由 SQL 承载
|
|
85
|
+
* (`listSessions({ owner, includeUnowned })` 的 `includeUnowned` 位即本函数的返回值)。
|
|
86
|
+
* 单实体读/写点(fork/delete/workspace)**一律**用三条件齐的 {@link legacyUnownedSessionOk},不要用本函数。
|
|
87
|
+
*/
|
|
88
|
+
export function legacyUnownedAmnestyOpen(requirePrincipal, gateOwner) {
|
|
89
|
+
return !requirePrincipal && gateOwner === LEGACY_UNOWNED_AMNESTY_TENANT;
|
|
90
|
+
}
|
|
40
91
|
/** The shape core's `uuidv7()` mints: canonical 8-4-4-4-12 lowercase hex with version nibble `7` and the RFC-4122
|
|
41
92
|
* variant (`8`/`9`/`a`/`b`). Used to shape-validate a caller-supplied session id on the §0.5 fork/delete routes so
|
|
42
93
|
* a crafted id (LIKE metacharacters, over-long, non-canonical) is rejected up front — a SECOND layer behind the
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 墙钟阶跃守卫 —— 给「按墙钟绝对 deadline 做终局判定」的清算腿加一道**单调钟佐证**。
|
|
3
|
+
*
|
|
4
|
+
* 病灶(test [4232] 场景①,libfaketime 真实构造,宿主真钟全程未碰):审批 SLA=45s,把进程内墙钟
|
|
5
|
+
* **前拨 1 小时**,真实只等了约 14 秒审批卡就被 D-D deny-sweep 判超时自动拒绝。链条是清楚的:
|
|
6
|
+
* `checkpoint.deadline` 是 core 在 `put` 时按**当时的墙钟**铸的绝对时刻(必须是墙钟——它要跨进程
|
|
7
|
+
* 重启存活,单调基准一重启就没了),而 sweep 每拍拿 `Date.now()` 去比 `deadline <= cutoff`。墙钟凭空
|
|
8
|
+
* 前跳 J 毫秒 ⇒ 判定钟凭空多走 J 毫秒 ⇒ 人还没走开,窗就没了。
|
|
9
|
+
*
|
|
10
|
+
* [4235] core 半场三定性把判定半场划给本仓:`totalWaitMs` 的墙钟**记账**是设计如此(要跨 durable
|
|
11
|
+
* resume 延续),不改形;**判定**侧可以在进程内用单调钟做窗口佐证,与账面字段互不牵连。本模块就是那道佐证。
|
|
12
|
+
*
|
|
13
|
+
* ## 判据(为什么是「宽限一个完整 SLA」)
|
|
14
|
+
* 直觉修法是把 cutoff 改成 `now - 累计前跳量`。它不成立:core 在跳变**之后**铸的 deadline 用的是**新**
|
|
15
|
+
* 墙钟,拿旧墙钟去比 ⇒ 新卡凭空多活 J 毫秒,而 J 可以是任意大。补偿量若衰减,cutoff 又会以 2 倍速前进。
|
|
16
|
+
* 两条路都把一个有界缺陷换成一个无界缺陷。
|
|
17
|
+
*
|
|
18
|
+
* 本模块改判**宽限**:检出一次前跳后,按**单调钟**静默 `grace = slaMs` 毫秒不做判定。
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **宽限量是一个完整 SLA,不是 `min(J, S)`**(codex 对抗复审 R1 [high] 三,验真后修 —— 第一版正是
|
|
21
|
+
* `min(J, S)`,而那条「J < S 时宽限恰好补平损失」的证明**是错的**)。错在哪:宽限只是**推迟**判定,
|
|
22
|
+
* 它消不掉墙钟从此**永久领先** J 毫秒这件事。反例(逐字复算):S=45s,gate 在真实 t=0 铸出(deadline =
|
|
23
|
+
* W0+45s),真实 t=1s 时前跳 J=10s。`min(J,S)`=10s ⇒ 宽限到真实 t=11s;此后墙钟恒为 `W0+t+10`,
|
|
24
|
+
* 于是 deadline 在 **真实 t=35s** 就被判到期 —— 仍旧少给了整整 10 秒。
|
|
25
|
+
*
|
|
26
|
+
* 取满一个 S 才成立,证明是:宽限结束的时刻 t_g = t_jump + S(单调钟量)。
|
|
27
|
+
* · 跳变**之前或当时**就在等的 gate:它在 t_g 之前不可能被判,故真实等待 ≥ S ✔
|
|
28
|
+
* · 跳变**之后**在 t_x 铸出的 gate:deadline 用的是跳变后的钟 ⇒ 它按墙钟到期的时刻恰好对应真实
|
|
29
|
+
* t_x + S(墙钟与单调钟从跳变后起同速),宽限只可能把它再往后推,绝不会提前 ✔
|
|
30
|
+
* 即**任一审批席都拿得到不少于一个完整 SLA 的真实时间**;单次阶跃下判定最多被推迟一个 S。
|
|
31
|
+
*
|
|
32
|
+
* 🔴 **「有界且一次性自清」只对单次阶跃成立**(codex 对抗复审 R2 [high] 五,验真后修 —— 第一版把这句话
|
|
33
|
+
* 写成了无条件的,那是错的):每次前跳都把窗延到「此刻 + 一个 S」,所以**只要每个 S 内再跳一次**,窗就
|
|
34
|
+
* 永远续下去,deny 腿被静默关停 —— 而它是一条 **fail-closed** 腿,永久不跑比跑早了更坏。
|
|
35
|
+
* 收口 = 一串连续跳变的**累计宽限总上限**(缺省 `2 × S`:一个 S 覆盖有证明的单次阶跃,第二个 S 容一串
|
|
36
|
+
* 短促跳变即 NTP 连续大步修正的真实形);撞上上限就**响亮**恢复判定(`onGraceCapped`),此后宁可按一个
|
|
37
|
+
* 不稳的钟判,也不让 fail-closed 腿永远不跑。绝对兜底始终另有一层:`checkpoint.terminal_at_ms` 由别的
|
|
38
|
+
* reap 腿执行、不经本守卫。
|
|
39
|
+
*
|
|
40
|
+
* ## 方向与响亮度(#157 安全轴)
|
|
41
|
+
* 宽限只会**推迟**一个 fail-closed 动作(deny),绝不会放行任何东西 —— 方向安全。但它必须**响亮**:
|
|
42
|
+
* 检出即 warn(带跳变量与宽限量),宽限期内每拍 info。绝对兜底仍在:`checkpoint.terminal_at_ms`
|
|
43
|
+
* (put 时钉死的绝对上限)由另一条 reap 腿执行,不经本守卫 ⇒ 悬挂不可能无限。
|
|
44
|
+
*
|
|
45
|
+
* 后拨(墙钟相对单调钟**倒退**)不触发宽限:它只会让 deadline 显得更远 ⇒ 判定被推迟,已经是安全方向。
|
|
46
|
+
* 仍然 warn —— 一次没人知道的钟倒退是运维事实,不是可以吞掉的噪声。
|
|
47
|
+
*/
|
|
48
|
+
/** 判定为「阶跃」的漂移阈值。低于它的漂移是 tick 抖动 / 事件循环延迟 / NTP 缓变(≤500ppm,60s 拍上仅 30ms),
|
|
49
|
+
* 不是拨表。误判的代价只是**推迟**一次 deny ≤ 宽限量,所以宁可取得低一些。 */
|
|
50
|
+
export declare const DEFAULT_CLOCK_JUMP_THRESHOLD_MS = 2000;
|
|
51
|
+
/** `slaMs` 缺省(部署没配 `APPROVAL_TIMEOUT_SEC` 时)—— 与 `tool-approval.ts` 的
|
|
52
|
+
* `DEFAULT_APPROVAL_TTL_MS` 同值同义:一次审批交互的量级。 */
|
|
53
|
+
export declare const DEFAULT_CLOCK_JUMP_GRACE_MS: number;
|
|
54
|
+
export interface WallClockJumpGuard {
|
|
55
|
+
/**
|
|
56
|
+
* 每次清算前调用一次,传入本次清算**要用的那个墙钟读数**(判定基准本身,不是另取一次 `Date.now()`
|
|
57
|
+
* ——否则守卫看的钟和判定用的钟不是同一个)。
|
|
58
|
+
*
|
|
59
|
+
* @returns `true` = 本拍可按墙钟判定;`false` = 刚检出前跳,单调宽限未走完,本拍**整拍让开**。
|
|
60
|
+
*/
|
|
61
|
+
admit(wallNowMs: number): boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface WallClockJumpGuardOptions {
|
|
64
|
+
/** 宽限上界 = 被保护窗口的 SLA(审批腿=`approvalTimeoutSec * 1000`);缺省 {@link DEFAULT_CLOCK_JUMP_GRACE_MS}。 */
|
|
65
|
+
slaMs?: number;
|
|
66
|
+
/** 单调钟 seam。缺省 `performance.now()` —— 本仓既定单调域(server.ts/shutdown.ts/sighup-idle.ts 同源)。 */
|
|
67
|
+
monotonic?: () => number;
|
|
68
|
+
/** 墙钟 seam,**只用于构造当拍取首个锚点**(判定仍用 `admit` 的入参)。缺省 `Date.now`。
|
|
69
|
+
* codex R2 [high] 五:没有它,`lastWall` 要等第一次 `admit` 才有值 ⇒ 构造之后、首拍之前的那次前跳
|
|
70
|
+
* **测不出来**,而那正是「进程刚起来、pending gate 已在库里」的真实窗口。 */
|
|
71
|
+
now?: () => number;
|
|
72
|
+
/** 阈值 seam;缺省 {@link DEFAULT_CLOCK_JUMP_THRESHOLD_MS}。 */
|
|
73
|
+
jumpThresholdMs?: number;
|
|
74
|
+
/** 一串连续跳变累计宽限的**总上限**,自本串第一次跳变起算;缺省 `2 × slaMs`(理由见 `admit` 内注)。 */
|
|
75
|
+
maxTotalGraceMs?: number;
|
|
76
|
+
/** 检出阶跃:`jumpMs > 0` = 前拨(触发宽限),`< 0` = 后拨(只留痕,不宽限)。 */
|
|
77
|
+
onJump?: (info: {
|
|
78
|
+
jumpMs: number;
|
|
79
|
+
graceMs: number;
|
|
80
|
+
}) => void;
|
|
81
|
+
/** 宽限期内每拍一次。 */
|
|
82
|
+
onSuppressed?: (info: {
|
|
83
|
+
remainingMs: number;
|
|
84
|
+
}) => void;
|
|
85
|
+
/** 累计宽限撞上总上限、守卫**强制恢复判定**时一次(响亮放行,不是悄悄放行)。 */
|
|
86
|
+
onGraceCapped?: (info: {
|
|
87
|
+
totalGraceMs: number;
|
|
88
|
+
}) => void;
|
|
89
|
+
}
|
|
90
|
+
export declare function createWallClockJumpGuard(opts?: WallClockJumpGuardOptions): WallClockJumpGuard;
|
|
91
|
+
//# sourceMappingURL=wall-clock-jump-guard.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 墙钟阶跃守卫 —— 给「按墙钟绝对 deadline 做终局判定」的清算腿加一道**单调钟佐证**。
|
|
3
|
+
*
|
|
4
|
+
* 病灶(test [4232] 场景①,libfaketime 真实构造,宿主真钟全程未碰):审批 SLA=45s,把进程内墙钟
|
|
5
|
+
* **前拨 1 小时**,真实只等了约 14 秒审批卡就被 D-D deny-sweep 判超时自动拒绝。链条是清楚的:
|
|
6
|
+
* `checkpoint.deadline` 是 core 在 `put` 时按**当时的墙钟**铸的绝对时刻(必须是墙钟——它要跨进程
|
|
7
|
+
* 重启存活,单调基准一重启就没了),而 sweep 每拍拿 `Date.now()` 去比 `deadline <= cutoff`。墙钟凭空
|
|
8
|
+
* 前跳 J 毫秒 ⇒ 判定钟凭空多走 J 毫秒 ⇒ 人还没走开,窗就没了。
|
|
9
|
+
*
|
|
10
|
+
* [4235] core 半场三定性把判定半场划给本仓:`totalWaitMs` 的墙钟**记账**是设计如此(要跨 durable
|
|
11
|
+
* resume 延续),不改形;**判定**侧可以在进程内用单调钟做窗口佐证,与账面字段互不牵连。本模块就是那道佐证。
|
|
12
|
+
*
|
|
13
|
+
* ## 判据(为什么是「宽限一个完整 SLA」)
|
|
14
|
+
* 直觉修法是把 cutoff 改成 `now - 累计前跳量`。它不成立:core 在跳变**之后**铸的 deadline 用的是**新**
|
|
15
|
+
* 墙钟,拿旧墙钟去比 ⇒ 新卡凭空多活 J 毫秒,而 J 可以是任意大。补偿量若衰减,cutoff 又会以 2 倍速前进。
|
|
16
|
+
* 两条路都把一个有界缺陷换成一个无界缺陷。
|
|
17
|
+
*
|
|
18
|
+
* 本模块改判**宽限**:检出一次前跳后,按**单调钟**静默 `grace = slaMs` 毫秒不做判定。
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **宽限量是一个完整 SLA,不是 `min(J, S)`**(codex 对抗复审 R1 [high] 三,验真后修 —— 第一版正是
|
|
21
|
+
* `min(J, S)`,而那条「J < S 时宽限恰好补平损失」的证明**是错的**)。错在哪:宽限只是**推迟**判定,
|
|
22
|
+
* 它消不掉墙钟从此**永久领先** J 毫秒这件事。反例(逐字复算):S=45s,gate 在真实 t=0 铸出(deadline =
|
|
23
|
+
* W0+45s),真实 t=1s 时前跳 J=10s。`min(J,S)`=10s ⇒ 宽限到真实 t=11s;此后墙钟恒为 `W0+t+10`,
|
|
24
|
+
* 于是 deadline 在 **真实 t=35s** 就被判到期 —— 仍旧少给了整整 10 秒。
|
|
25
|
+
*
|
|
26
|
+
* 取满一个 S 才成立,证明是:宽限结束的时刻 t_g = t_jump + S(单调钟量)。
|
|
27
|
+
* · 跳变**之前或当时**就在等的 gate:它在 t_g 之前不可能被判,故真实等待 ≥ S ✔
|
|
28
|
+
* · 跳变**之后**在 t_x 铸出的 gate:deadline 用的是跳变后的钟 ⇒ 它按墙钟到期的时刻恰好对应真实
|
|
29
|
+
* t_x + S(墙钟与单调钟从跳变后起同速),宽限只可能把它再往后推,绝不会提前 ✔
|
|
30
|
+
* 即**任一审批席都拿得到不少于一个完整 SLA 的真实时间**;单次阶跃下判定最多被推迟一个 S。
|
|
31
|
+
*
|
|
32
|
+
* 🔴 **「有界且一次性自清」只对单次阶跃成立**(codex 对抗复审 R2 [high] 五,验真后修 —— 第一版把这句话
|
|
33
|
+
* 写成了无条件的,那是错的):每次前跳都把窗延到「此刻 + 一个 S」,所以**只要每个 S 内再跳一次**,窗就
|
|
34
|
+
* 永远续下去,deny 腿被静默关停 —— 而它是一条 **fail-closed** 腿,永久不跑比跑早了更坏。
|
|
35
|
+
* 收口 = 一串连续跳变的**累计宽限总上限**(缺省 `2 × S`:一个 S 覆盖有证明的单次阶跃,第二个 S 容一串
|
|
36
|
+
* 短促跳变即 NTP 连续大步修正的真实形);撞上上限就**响亮**恢复判定(`onGraceCapped`),此后宁可按一个
|
|
37
|
+
* 不稳的钟判,也不让 fail-closed 腿永远不跑。绝对兜底始终另有一层:`checkpoint.terminal_at_ms` 由别的
|
|
38
|
+
* reap 腿执行、不经本守卫。
|
|
39
|
+
*
|
|
40
|
+
* ## 方向与响亮度(#157 安全轴)
|
|
41
|
+
* 宽限只会**推迟**一个 fail-closed 动作(deny),绝不会放行任何东西 —— 方向安全。但它必须**响亮**:
|
|
42
|
+
* 检出即 warn(带跳变量与宽限量),宽限期内每拍 info。绝对兜底仍在:`checkpoint.terminal_at_ms`
|
|
43
|
+
* (put 时钉死的绝对上限)由另一条 reap 腿执行,不经本守卫 ⇒ 悬挂不可能无限。
|
|
44
|
+
*
|
|
45
|
+
* 后拨(墙钟相对单调钟**倒退**)不触发宽限:它只会让 deadline 显得更远 ⇒ 判定被推迟,已经是安全方向。
|
|
46
|
+
* 仍然 warn —— 一次没人知道的钟倒退是运维事实,不是可以吞掉的噪声。
|
|
47
|
+
*/
|
|
48
|
+
/** 判定为「阶跃」的漂移阈值。低于它的漂移是 tick 抖动 / 事件循环延迟 / NTP 缓变(≤500ppm,60s 拍上仅 30ms),
|
|
49
|
+
* 不是拨表。误判的代价只是**推迟**一次 deny ≤ 宽限量,所以宁可取得低一些。 */
|
|
50
|
+
export const DEFAULT_CLOCK_JUMP_THRESHOLD_MS = 2_000;
|
|
51
|
+
/** `slaMs` 缺省(部署没配 `APPROVAL_TIMEOUT_SEC` 时)—— 与 `tool-approval.ts` 的
|
|
52
|
+
* `DEFAULT_APPROVAL_TTL_MS` 同值同义:一次审批交互的量级。 */
|
|
53
|
+
export const DEFAULT_CLOCK_JUMP_GRACE_MS = 5 * 60_000;
|
|
54
|
+
export function createWallClockJumpGuard(opts = {}) {
|
|
55
|
+
const monotonic = opts.monotonic ?? (() => performance.now());
|
|
56
|
+
const threshold = opts.jumpThresholdMs ?? DEFAULT_CLOCK_JUMP_THRESHOLD_MS;
|
|
57
|
+
const graceCap = opts.slaMs !== undefined && opts.slaMs > 0 ? opts.slaMs : DEFAULT_CLOCK_JUMP_GRACE_MS;
|
|
58
|
+
// codex R2 [high] 五(验真后修):首拍锚点**真的**在构造时取(旧形只初始化了单调锚,墙钟锚留 undefined,
|
|
59
|
+
// 而注释却写着「构造时取」—— 注在撒谎,且后果具体:构造之后、第一次 admit 之前的那次前跳测不出来,
|
|
60
|
+
// 那正是「进程刚起来、pending gate 已在库里」的真实窗口)。
|
|
61
|
+
let lastWall = (opts.now ?? Date.now)();
|
|
62
|
+
let lastMono = monotonic();
|
|
63
|
+
let graceUntilMono;
|
|
64
|
+
/** 本串连续跳变的起点(单调钟)。`undefined` = 当前不在宽限中。 */
|
|
65
|
+
let burstStartMono;
|
|
66
|
+
// 一串跳变累计宽限的总上限。为什么需要它(codex R2 [high] 五的第二半):每次前跳都把窗延到「此刻 +
|
|
67
|
+
// 一个 SLA」,于是**只要每个 SLA 内再跳一次**,窗就永远续下去 —— deny 腿被静默关停,而它是一条
|
|
68
|
+
// **fail-closed** 腿,永久不跑比跑早了更坏。旧注「有界且一次性自清」在连续跳变下是假的。
|
|
69
|
+
// 2×SLA 的取值:一个 SLA 覆盖单次阶跃(那是有证明的那一支),第二个 SLA 容一串短促跳变(NTP 连续
|
|
70
|
+
// 大步修正的真实形);再往后钟已属病态,此时**宁可按一个不稳的钟判**也不能让 fail-closed 腿永远不跑。
|
|
71
|
+
const maxTotalGrace = opts.maxTotalGraceMs !== undefined && opts.maxTotalGraceMs > 0 ? opts.maxTotalGraceMs : graceCap * 2;
|
|
72
|
+
return {
|
|
73
|
+
admit(wallNowMs) {
|
|
74
|
+
const mono = monotonic();
|
|
75
|
+
const prevWall = lastWall;
|
|
76
|
+
const monoDelta = mono - lastMono;
|
|
77
|
+
lastWall = wallNowMs;
|
|
78
|
+
lastMono = mono;
|
|
79
|
+
// 漂移 = 墙钟走的量 − 单调钟走的量。两者本该逐毫秒并进;差额就是拨表量。
|
|
80
|
+
const driftMs = wallNowMs - prevWall - monoDelta;
|
|
81
|
+
if (driftMs > threshold) {
|
|
82
|
+
// codex R1 [high] 三:**整一个 SLA**,与跳变量无关(`min(driftMs, graceCap)` 的证明是错的,
|
|
83
|
+
// 反例逐字见模块头注 —— 宽限推迟不了「墙钟从此永久领先」这件事)。
|
|
84
|
+
const graceMs = graceCap;
|
|
85
|
+
if (burstStartMono === undefined)
|
|
86
|
+
burstStartMono = mono; // 本串的起点(总上限自它起算)
|
|
87
|
+
const until = mono + graceMs;
|
|
88
|
+
// 取 max:宽限期内又跳一次 ⇒ 窗延到更晚的那个,不被后一次的小跳缩短。
|
|
89
|
+
if (graceUntilMono === undefined || until > graceUntilMono)
|
|
90
|
+
graceUntilMono = until;
|
|
91
|
+
opts.onJump?.({ jumpMs: Math.round(driftMs), graceMs: Math.round(graceMs) });
|
|
92
|
+
}
|
|
93
|
+
else if (driftMs < -threshold) {
|
|
94
|
+
// 后拨:判定被推迟=安全方向,不宽限(宽限只会把安全方向再推远)。但要留痕。
|
|
95
|
+
opts.onJump?.({ jumpMs: Math.round(driftMs), graceMs: 0 });
|
|
96
|
+
}
|
|
97
|
+
if (graceUntilMono !== undefined && burstStartMono !== undefined) {
|
|
98
|
+
const elapsedInBurst = mono - burstStartMono;
|
|
99
|
+
if (elapsedInBurst >= maxTotalGrace) {
|
|
100
|
+
// 撞上总上限 ⇒ **响亮**恢复判定(不是悄悄放行)。绝对兜底另有一层:`checkpoint.terminal_at_ms`
|
|
101
|
+
// 由别的 reap 腿执行、不经本守卫,所以即使这里也失灵,悬挂仍不可能无限。
|
|
102
|
+
opts.onGraceCapped?.({ totalGraceMs: Math.round(elapsedInBurst) });
|
|
103
|
+
graceUntilMono = undefined;
|
|
104
|
+
burstStartMono = undefined;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (mono < graceUntilMono) {
|
|
108
|
+
opts.onSuppressed?.({ remainingMs: Math.round(graceUntilMono - mono) });
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
graceUntilMono = undefined;
|
|
112
|
+
burstStartMono = undefined;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=wall-clock-jump-guard.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.31.0-rc.1",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { FileError, StubExecutionEnv, type Result } from "@sema-agent/core";
|
|
2
|
-
/**
|
|
3
|
-
* 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
|
|
4
|
-
* 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
|
|
5
|
-
* 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
|
|
6
|
-
*/
|
|
7
|
-
export declare class LexicalPathExecutionEnv extends StubExecutionEnv {
|
|
8
|
-
absolutePath(path: string): Promise<Result<string, FileError>>;
|
|
9
|
-
}
|
|
10
|
-
//# sourceMappingURL=lexical-path-env.d.ts.map
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #156 —— 非 host lane(e2b/k8s/ssh/adb/local-docker)写门的**纯路径规则**过渡形所用的裁决 env。
|
|
3
|
-
*
|
|
4
|
-
* ## 它解决的缺口
|
|
5
|
-
*
|
|
6
|
-
* core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 都把写目标交给
|
|
7
|
-
* `canonicalizeTarget(env, …)` 去「问 fs 要真身」。host lane 上那个 env 就是 hand 工具真正写的那块盘,
|
|
8
|
-
* 答案可信。沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸,
|
|
9
|
-
* spec 期手边只有 worker 本机的 fs——拿它去裁沙箱里的路径会答错(exists/symlink 全是别人机器上的事实),
|
|
10
|
-
* 而**错误的 allow 比没有门更糟**。所以 [816]/[820] 当年在沙箱 lane 诚实不挂门(wiring=undefined ⇒
|
|
11
|
-
* 三个模式臂全回落 base 规则,写门整条缺席)。#153 的 ③ 号注把这笔记成了余款,#156 是它的过渡还款。
|
|
12
|
-
*
|
|
13
|
-
* ## 这个 env 是什么
|
|
14
|
-
*
|
|
15
|
-
* 一个**没有文件系统**的 env:除 `absolutePath` 外的每一个 fs 面(exists / canonicalPath / fileInfo /
|
|
16
|
-
* read* / write* / listDir / createDir / …)与 shell 面都继承 core 的 `StubExecutionEnv`,
|
|
17
|
-
* 恒返回 `not_supported` 错误(`readLink` 是可选面,Stub 干脆没有——core 把缺席当「symlink 不可解」,
|
|
18
|
-
* 且该分支在本 env 恒不可达:exists 先错就进了 canonicalizeNewPath)——这不是伪装,这就是本 env 的诚实回答:**spec 期我们确实不知道沙箱里
|
|
19
|
-
* 那块盘上有什么**。`absolutePath` 是唯一被赋予真实语义的一面,而它本来就是纯路径运算(不碰盘):
|
|
20
|
-
* POSIX 绝对路径做词法归一后原样奉还,其余一律「不知道」。
|
|
21
|
-
*
|
|
22
|
-
* ## 由此得到的裁决语义(core dist 亲读推出,`tools/fs/safety.js` canonicalizeTarget)
|
|
23
|
-
*
|
|
24
|
-
* · 绝对路径(`/…`):`absolutePath` ok ⇒ core 进 exists 探测 ⇒ 我们返回错误 ⇒ core 按「该路径尚不存在」
|
|
25
|
-
* 走 `canonicalizeNewPath`,那里逐级 exists 也全错 ⇒ 原样返回我们给的词法绝对路径。于是 canonical key
|
|
26
|
-
* = **词法归一后的路径**,`canonicalPath` / `fileInfo` / `readLink` 三面在这条路上根本不可达。
|
|
27
|
-
* · 相对路径 / `~/…` / Windows 盘符形:`absolutePath` 返回错误 ⇒ `canon.ok=false` ⇒ 写门直接
|
|
28
|
-
* `ask`(fail-closed)。沙箱的 cwd 在 spec 期不可知,这正是我们要的偏置——**不猜沙箱工作目录**。
|
|
29
|
-
* · 反斜杠 UNC 形(`\\host\share\…`)是唯一**不经过**本 env 的形:core 的 `canonicalizeTarget` 在入口
|
|
30
|
-
* 就把它短路成 `ok:true, key=原样`(复审 2026-08-05 dist 亲读+实测)。结果不变——写门无放行域可越,
|
|
31
|
-
* 落 defaultWrite 的 `ask`;sensitive 段匹配按 `[\\/]` 双分隔符切段,`\\host\share\.env` 照样 deny。
|
|
32
|
-
* · sensitivePatterns 的 deny 腿照常施加:它按 canonical key 的**路径段**做 glob 匹配,词法 key 足够,
|
|
33
|
-
* 且在 `combinePolicies` 折叠里 deny 恒胜(session 豁免 / accept 域越不过)。
|
|
34
|
-
* · `isExempt`(会话「本会话不再询问」探针)是 name-keyed 的,不做 fs 裁决,任何 lane 都安全,照接。
|
|
35
|
-
*
|
|
36
|
-
* ## 已知残余面(过渡形的边界,不是疏漏)
|
|
37
|
-
*
|
|
38
|
-
* · **symlink 形**:词法裁决看不见符号链接。沙箱里一个名字普通的软链可以指向守卫段(`.ssh` 等),
|
|
39
|
-
* 我们只会给 `ask` 而不是 `deny`;host lane 上 core 会 canonicalize 出真身并 deny。相应地,core 的
|
|
40
|
-
* `unresolvedSymlink ⇒ deny` 那条腿在本 env 下永不触发。注意豁免会话下的口径(codex 复审 E):
|
|
41
|
-
* name-keyed 豁免不看路径,这类词法无害的软链目标在豁免会话里是 **allow** 而非 ask——这是本残余面
|
|
42
|
-
* 在「操作员已授 don't-ask-again」情形下的完整代价,host lane 同情形仍会 deny。
|
|
43
|
-
* · **exempt/accept 域**:沙箱 lane 既无可信 cwd 也无 scratchpad 对应物,故本过渡形**一个自动放行域都
|
|
44
|
-
* 不铸**(见 resolve-spec 的 wiring)。代价是 acceptEdits 在沙箱 lane 退化成与 default 同形(全 ask);
|
|
45
|
-
* 这是 fail-safe 方向,与 host lane「accept 域解析不出 ⇒ 该域不生效」的哨兵先例同口径。
|
|
46
|
-
* · **`cwd` 字段**:继承 `StubExecutionEnv` 的 `"/"` 占位。本 env 只活在 policy 折叠里,core 的两个
|
|
47
|
-
* policy 都不读 `env.cwd`(相对路径基准走 `rootPath` 形参,而我们不给)——它不代表沙箱的工作目录。
|
|
48
|
-
*
|
|
49
|
-
* ## 终局 seam
|
|
50
|
-
*
|
|
51
|
-
* 正解是让写门在**工具执行时刻**拿到真沙箱 env:core [2751] 已排期给 `HookToolContext` 加一个只读的
|
|
52
|
-
* env 能力窄面(174/#22 窗)。那一面到货后,本过渡形整体退役换正解——届时沙箱 lane 与 host lane 走同
|
|
53
|
-
* 一条真 fs 裁决,上面三条残余面一并消失。
|
|
54
|
-
*/
|
|
55
|
-
import { posix } from "node:path";
|
|
56
|
-
import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
|
|
57
|
-
/** 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,不碰 fs。
|
|
58
|
-
* `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「沙箱根之上没有东西」的语义一致。
|
|
59
|
-
* 尾部分隔符统一剥掉(根 `/` 除外),让同一目标只有一个 key ——前缀判域靠的就是 key 的唯一性。
|
|
60
|
-
* (core 内部有同形的 `normalizeAbsPathLexically`,但未从包根导出;此处是 Node 标准库的等价运算,
|
|
61
|
-
* 不是它的抄本——若日后 core 导出,这里应改为直接复用。) */
|
|
62
|
-
function normalizeAbsolutePathLexically(path) {
|
|
63
|
-
const collapsed = posix.normalize(path.replace(/^\/+/, "/"));
|
|
64
|
-
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
|
|
68
|
-
* 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
|
|
69
|
-
* 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
|
|
70
|
-
*/
|
|
71
|
-
export class LexicalPathExecutionEnv extends StubExecutionEnv {
|
|
72
|
-
absolutePath(path) {
|
|
73
|
-
// POSIX 绝对形是沙箱 lane(全 Linux)上 hand 工具的书面契约形。其余一切——相对路径、`~`、
|
|
74
|
-
// `C:\…`——在 spec 期都无法诚实解析成一个沙箱内的绝对路径,返回错误让 core 落 ask。
|
|
75
|
-
// (反斜杠 UNC 形根本到不了这里:core 在 canonicalizeTarget 入口短路,见文件头「由此得到的裁决语义」。)
|
|
76
|
-
// codex 复审 A(2026-08-05):含 NUL 的字符串不是合法 POSIX 路径(任何 fs 面都写不进去),但词法归一
|
|
77
|
-
// 会照样给它铸出 canonical key——`\0` 尾巴让守卫段 glob 失配,豁免会话下还能拿 allow。拒收让 canon
|
|
78
|
-
// 在豁免咨询**之前**就失败 ⇒ 恒 ask。
|
|
79
|
-
if (path.includes("\u0000")) {
|
|
80
|
-
return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and rejects a path containing a NUL byte (not a representable POSIX path)", path)));
|
|
81
|
-
}
|
|
82
|
-
if (!path.startsWith("/")) {
|
|
83
|
-
return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and cannot resolve a non-absolute path (the sandbox working directory is unknown at spec time)", path)));
|
|
84
|
-
}
|
|
85
|
-
return Promise.resolve(ok(normalizeAbsolutePathLexically(path)));
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
//# sourceMappingURL=lexical-path-env.js.map
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { ToolSpec } from "@sema-agent/core";
|
|
2
|
-
/**
|
|
3
|
-
* OA scenario — tools are thin HTTP clients to the OA backend's existing APIs (read = /api/v1/*,
|
|
4
|
-
* write = /api/assets/*). The shared service token + the request's principal are forwarded so OA
|
|
5
|
-
* enforces THAT end-user's RBAC + audit (the service never holds per-user creds). Decoupled: the
|
|
6
|
-
* service knows no OA schema/DB — OA stays the source of truth (its /api/v1/meta is self-describing).
|
|
7
|
-
*/
|
|
8
|
-
export interface OaCtx {
|
|
9
|
-
baseUrl: string;
|
|
10
|
-
token: string;
|
|
11
|
-
principal?: string;
|
|
12
|
-
}
|
|
13
|
-
export declare function oaTools(ctx: OaCtx): ToolSpec[];
|
|
14
|
-
export declare const oaPrompt: import("@sema-agent/core").PromptProvider;
|
|
15
|
-
//# sourceMappingURL=oa-tools.d.ts.map
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { Type } from "typebox";
|
|
2
|
-
import { stablePrompt } from "./prompt.js";
|
|
3
|
-
async function oaFetch(ctx, path, init) {
|
|
4
|
-
if (!ctx.baseUrl || !ctx.token)
|
|
5
|
-
return "OA 后端未配置(OA_API_BASEURL / OA_SERVICE_TOKEN)";
|
|
6
|
-
try {
|
|
7
|
-
const res = await fetch(ctx.baseUrl + path, {
|
|
8
|
-
method: init?.method ?? "GET",
|
|
9
|
-
headers: {
|
|
10
|
-
"content-type": "application/json",
|
|
11
|
-
authorization: `Bearer ${ctx.token}`,
|
|
12
|
-
...(ctx.principal ? { "x-agent-principal": ctx.principal } : {}),
|
|
13
|
-
},
|
|
14
|
-
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
15
|
-
});
|
|
16
|
-
const text = await res.text();
|
|
17
|
-
if (!res.ok)
|
|
18
|
-
return `OA ${res.status}: ${text.slice(0, 600)}`;
|
|
19
|
-
return text.slice(0, 60_000);
|
|
20
|
-
}
|
|
21
|
-
catch (e) {
|
|
22
|
-
return `调用 OA 失败:${e.message}`;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const needWrite = (ctx) => (!ctx.principal ? "缺少身份(principal),无法代表用户写库;请让调用方带上 x-agent-principal。" : null);
|
|
26
|
-
export function oaTools(ctx) {
|
|
27
|
-
const obj = Type.Object({}, { additionalProperties: true });
|
|
28
|
-
return [
|
|
29
|
-
{ name: "oa_schema", effect: "read", description: "取 OA 数据结构(每类资产的字段/必填/条件必填 requiredWhen/联动 showWhen/枚举、生命周期、表名)。建/改前先调它,别凭记忆。", parameters: Type.Object({}), execute: () => oaFetch(ctx, "/api/v1/meta") },
|
|
30
|
-
{ name: "oa_alerts", effect: "read", description: "派生告警:GP 闲置(已注册未变现) / APP 未上架(已接广告未 LIVE)。", parameters: Type.Object({}), execute: () => oaFetch(ctx, "/api/v1/alerts") },
|
|
31
|
-
{ name: "oa_list", effect: "read", description: "列出某类资产。type∈gp/package/ad-platform/ad-unit/email/environment/proxy。可选 q 关键字。", parameters: Type.Object({ type: Type.String(), q: Type.Optional(Type.String()) }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/entities/${encodeURIComponent(x.type)}${x.q ? `?q=${encodeURIComponent(x.q)}` : ""}`); } },
|
|
32
|
-
{ name: "oa_get", effect: "read", description: "取某资产详情(敏感字段为掩码)。", parameters: Type.Object({ type: Type.String(), id: Type.String() }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/entities/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}`); } },
|
|
33
|
-
{ name: "oa_lookup", effect: "read", description: "约定字段值反查(同手机/证件=同一实物)。field: phone|idCardNo|bizLicenseNo。", parameters: Type.Object({ field: Type.String(), value: Type.String() }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/lookup?field=${encodeURIComponent(x.field)}&value=${encodeURIComponent(x.value)}`); } },
|
|
34
|
-
{ name: "oa_create", effect: "write", description: "创建实体(走 OA 校验+当前用户 RBAC+审计)。type + data(字段对象,参照 oa_schema)。失败会返回缺失/非法字段。", parameters: Type.Object({ type: Type.String(), data: obj }), execute: (a) => { const e = needWrite(ctx); if (e)
|
|
35
|
-
return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}`, { method: "POST", body: x.data ?? {} }); } },
|
|
36
|
-
{ name: "oa_update", effect: "write", description: "改字段(敏感留空=保持原值;状态字段改不动,用 oa_transition)。", parameters: Type.Object({ type: Type.String(), id: Type.String(), data: obj }), execute: (a) => { const e = needWrite(ctx); if (e)
|
|
37
|
-
return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}`, { method: "PATCH", body: x.data ?? {} }); } },
|
|
38
|
-
{ name: "oa_transition", effect: "write", description: "受控状态流转(先 oa_schema 看可用迁移;风险/弃用等需 note)。", parameters: Type.Object({ type: Type.String(), id: Type.String(), to: Type.String(), note: Type.Optional(Type.String()) }), execute: (a) => { const e = needWrite(ctx); if (e)
|
|
39
|
-
return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}/transition`, { method: "POST", body: { to: x.to, note: x.note ?? null } }); } },
|
|
40
|
-
];
|
|
41
|
-
}
|
|
42
|
-
// OA business prompt MOVED OUT — migration complete (design/29, 2026-06-05). Persona / 工作准则 / UI
|
|
43
|
-
// markers (::FORM:: / ::GOTO:: / ::SUGGEST:: / ::ISSUE_FORM::) / 分级确认 / 报bug流程 are OA's business,
|
|
44
|
-
// and OA now injects the WHOLE thing per request via `request.systemPrompt` (it holds the service token,
|
|
45
|
-
// content is stable per OA version → cacheable prefix; page context still rides `objective`). Keeping
|
|
46
|
-
// any of it here would just DUPLICATE OA's prompt, so the base is EMPTY — single source of truth = OA.
|
|
47
|
-
// The service stays business-agnostic: it provides the oa_* tools (referenced by NAME from OA's prompt;
|
|
48
|
-
// they carry their own descriptions) and nothing else. `stablePrompt` keeps STABLE→VARIABLE order —
|
|
49
|
-
// OA's systemPrompt is appended after this (empty) base, before core's <user_memory>.
|
|
50
|
-
// (::ISSUE_FORM:: is parsed/rendered + submitted entirely by OA's frontend via OA /api/ai/issue — the
|
|
51
|
-
// service never touches it; `open_gitea_issue` remains only as the unattended/automated fallback.)
|
|
52
|
-
const OA_SYSTEM = "";
|
|
53
|
-
export const oaPrompt = stablePrompt(OA_SYSTEM);
|
|
54
|
-
//# sourceMappingURL=oa-tools.js.map
|