@sema-agent/server 7.119.0 → 7.120.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/MIGRATION.md +54 -0
- package/USAGE.md +5 -3
- package/dist/boot/config-center.js +5 -5
- package/dist/boot/execution-env.js +3 -2
- package/dist/boot/fatal-exit.d.ts +7 -1
- package/dist/boot/fatal-exit.js +2 -1
- package/dist/boot/runner-deps.d.ts +4 -3
- package/dist/boot/runner-deps.js +7 -3
- package/dist/boot/stage-01-config.js +2 -0
- package/dist/capabilities/builtin-tools.d.ts +31 -8
- package/dist/capabilities/builtin-tools.js +22 -1
- package/dist/capabilities/scenarios.d.ts +8 -6
- package/dist/capabilities/scenarios.js +3 -3
- package/dist/capabilities/send-user-file-tool.d.ts +6 -3
- package/dist/capabilities/send-user-file-tool.js +1 -1
- package/dist/config-catalog.js +2 -2
- package/dist/config-lkg.d.ts +33 -5
- package/dist/config-lkg.js +55 -11
- package/dist/config-types.d.ts +8 -1
- package/dist/config.js +9 -9
- package/dist/deployment-governance.d.ts +3 -1
- package/dist/deployment-governance.js +3 -1
- package/dist/device-store.d.ts +7 -0
- package/dist/device-store.js +4 -0
- package/dist/device-ws-hub.js +17 -9
- package/dist/device-ws-protocol.d.ts +15 -0
- package/dist/http/resume-legs.js +4 -0
- package/dist/http/routes/capabilities.js +1 -0
- package/dist/http/routes/devices.js +3 -3
- package/dist/http/routes/rules.js +13 -6
- package/dist/list-entry-display.d.ts +14 -0
- package/dist/list-entry-display.js +14 -0
- package/dist/namespaced-rule-name.js +2 -6
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/operator-ask.d.ts +1 -1
- package/dist/plugins/one-time-migrations.d.ts +8 -0
- package/dist/plugins/one-time-migrations.js +72 -3
- package/dist/plugins/permission-rule-store-file.js +2 -6
- package/dist/plugins/permission-rule-store-sql.d.ts +64 -6
- package/dist/plugins/permission-rule-store-sql.js +166 -59
- package/dist/plugins/remote-env-device.d.ts +12 -1
- package/dist/plugins/remote-env-device.js +13 -5
- package/dist/plugins/scheduler-support.d.ts +8 -4
- package/dist/plugins/scheduler-support.js +5 -5
- package/dist/rule-entry-list.d.ts +0 -1
- package/dist/rule-entry-list.js +4 -9
- package/dist/rules-consent.d.ts +12 -3
- package/dist/rules-consent.js +5 -4
- package/dist/run-local.js +2 -0
- package/dist/sema-config-dir.d.ts +19 -0
- package/dist/sema-config-dir.js +33 -0
- package/dist/session-policy-wire.js +5 -4
- package/dist/spec-fields.d.ts +5 -4
- package/dist/task-settings.d.ts +3 -3
- package/dist/trace/engine-notice-wire.d.ts +6 -4
- package/dist/trace/engine-notice-wire.js +16 -9
- package/dist/trace/self-minted-wire.d.ts +14 -0
- package/dist/trace/self-minted-wire.js +2 -0
- package/docs/DEVICE-EXECUTOR-PROTOCOL.md +56 -14
- package/package.json +2 -2
|
@@ -4,16 +4,12 @@ import { readdirSync } from "node:fs";
|
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
5
|
import { FileDurableRulePartitionProvider, AppendLog, ensureDir, } from "@sema-agent/core";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
7
|
+
import { DurableRuleScopeSchema, buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
8
8
|
import { replayJsonlGuarded } from "../observability/corrupt-read-seat.js";
|
|
9
9
|
import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
10
10
|
import { RULE_BEHAVIORS } from "@sema-agent/core";
|
|
11
|
-
const RuleScopeSchema = z.union([
|
|
12
|
-
z.object({ kind: z.literal("global") }).strict(),
|
|
13
|
-
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
14
|
-
]);
|
|
15
11
|
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
16
|
-
const RuleCandidateSchema = z.object({ behavior: z.enum(RULE_BEHAVIORS), rule: z.string(), scope:
|
|
12
|
+
const RuleCandidateSchema = z.object({ behavior: z.enum(RULE_BEHAVIORS), rule: z.string(), scope: DurableRuleScopeSchema }).strict();
|
|
17
13
|
const RuleOffer2Schema = z.union([
|
|
18
14
|
z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
|
|
19
15
|
z
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleCandidate, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
|
|
2
|
+
import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleBehavior, type RuleCandidate, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
|
|
3
3
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
4
4
|
import type { PgQueryFn } from "./pg-query.js";
|
|
5
5
|
import { type SqlDriver } from "./sql-driver.js";
|
|
@@ -16,13 +16,19 @@ import { type IndexSpec } from "./ensure-index.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined;
|
|
18
18
|
/**
|
|
19
|
-
* 382P1(core 7.0.0 [ref] §4.3)——
|
|
20
|
-
*
|
|
19
|
+
* 382P1(core 7.0.0 [ref] §4.3)—— DURABLE 二员面的 at-rest **形**:`{kind:"global"}` | `{kind:"project", root:string}`。
|
|
20
|
+
*
|
|
21
|
+
* 🔴 **只判形,不判 root 收不收**。「这个 root 能不能当持久项目 scope」只有一只判据 —— core 的 `isValidDurableScope`
|
|
22
|
+
* (7.46.0 起 root 必须是完整绝对路径):写臂在 {@link SqlDurableRulePartition.apply} 里经 core 的
|
|
23
|
+
* `assertWriteDeltaScopeDurable` 先拒,读侧由 core 的统一店在有效视图里丢掉那一行并发 `permission.rule_row_dropped`
|
|
24
|
+
* (一行的坏只丢那一行)。本 schema 若再判一次 root,就是第二份判据:它与 core 答案不同的那一天(`proj` / `C:`
|
|
25
|
+
* 本仓收、引擎丢;空 root 本仓整桶拒、引擎只丢一行),同一行在两侧是两种命运。core 自己的 File 参照后端在 at-rest
|
|
26
|
+
* 上同样只判形。入口(wire)的判据见 `rules-consent.ts` 的 `parseRuleScope`(调同一只谓词)。
|
|
27
|
+
*
|
|
21
28
|
* `RuleScope` 联合自 v7 起有第三员 `session`,但它**结构上**不许进持久店(家在会话 overlay,随会话死):
|
|
22
29
|
* at-rest 解码不认它 ⇒ 一条被外部写进来的 session 行整桶拒读(fail-closed),写臂另由 core 的
|
|
23
|
-
* `assertWriteDeltaScopeDurable` 在任何 DB 往返之前以典型码拒(见 `apply`)
|
|
24
|
-
*
|
|
25
|
-
* `RuleCandidateSchema`)——那是本仓自己的窄型收口,不是把两张表合成一张。
|
|
30
|
+
* `assertWriteDeltaScopeDurable` 在任何 DB 往返之前以典型码拒(见 `apply`)。审批记录的候选也用这张表
|
|
31
|
+
* (下方 `RuleCandidateSchema`;File 店的审批记录读同一只)。
|
|
26
32
|
*/
|
|
27
33
|
export declare const DurableRuleScopeSchema: z.ZodType<RuleScope>;
|
|
28
34
|
export declare const PERMISSION_RULE_TABLE = "permission_rule";
|
|
@@ -406,6 +412,58 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
|
|
|
406
412
|
* 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
|
|
407
413
|
*/
|
|
408
414
|
export declare const RULE_REAP_BATCH = 500;
|
|
415
|
+
/**
|
|
416
|
+
* M11 删掉的一行。只带桶键、态、工具名与 scope 的 root —— 规则正文不出本函数(命令行里可以有任何东西)。
|
|
417
|
+
*
|
|
418
|
+
* `kind` 是闭集(启动日志按它穷尽分句,`one-time-migrations.ts` `logOneTimeMigrations`):
|
|
419
|
+
* · `rule` —— 仍有活 add 的规则行(引擎读侧每跑一次丢它并告知;它从未生效,要的话按项目的完整路径重写);
|
|
420
|
+
* · `revoked-rule` —— 每个 add 都已被同桶墓碑撤掉的规则行(判活用 core 的 `applyTombstones`):它什么都不授予,人的撤销照旧成立,无需处置;
|
|
421
|
+
* · `tombstone` —— 一条撤销记录。它只盖得住同 (behavior, 文本, scope) 的规则 ⇒ 那些规则的 scope 与它同样不被收,随它一起删了或本来就不在,
|
|
422
|
+
* 删它不会让任何被撤销的东西回来。
|
|
423
|
+
*/
|
|
424
|
+
export interface UndurableScopeRuleRow {
|
|
425
|
+
/** 桶键(sha256,不是 principal 原文)。 */
|
|
426
|
+
readonly ownerKey: string;
|
|
427
|
+
readonly kind: "rule" | "revoked-rule" | "tombstone";
|
|
428
|
+
readonly behavior: RuleBehavior;
|
|
429
|
+
readonly tool: string;
|
|
430
|
+
/** scope 的 root,**过 `redactSecrets`**(本仓唯一脱敏器):坏 root 是最不可信的自由文本,可能夹着凭据形,而本读数进启动日志与拒因。 */
|
|
431
|
+
readonly scopeRoot: string;
|
|
432
|
+
}
|
|
433
|
+
/** M11 判成读不出、一个字节没动的桶。`why` = 唯一判官那次整桶拒**不带任何行内容**的拒因(读腿对这只桶照旧整桶拒,它仍是这只桶的属主)。 */
|
|
434
|
+
export interface UnreadableRuleBucket {
|
|
435
|
+
/** 桶键(sha256,不是 principal 原文)。 */
|
|
436
|
+
readonly ownerKey: string;
|
|
437
|
+
readonly why: string;
|
|
438
|
+
}
|
|
439
|
+
/** M11 一次跑完的读数。 */
|
|
440
|
+
export interface UndurableScopePurge {
|
|
441
|
+
readonly removed: readonly UndurableScopeRuleRow[];
|
|
442
|
+
/** 连续争不过在线写者(OCC 每次都输)而留到下一次起服的桶数。那些桶里的坏行这期间照旧由引擎读侧丢掉并告知。 */
|
|
443
|
+
readonly bucketsDeferred: number;
|
|
444
|
+
/** 读腿整桶拒的桶(形 / 指纹 / behavior 闭集 / 墓碑规范文本 / 单一验证器):一个字节不动,逐桶交出拒因,等人处置。 */
|
|
445
|
+
readonly bucketsUnreadable: readonly UnreadableRuleBucket[];
|
|
446
|
+
}
|
|
447
|
+
/** {@link purgeUndurableScopeRows} 要的最小查询口(一次性迁移段的 `MigrationDb` 同形;占位符按方言由本函数写)。 */
|
|
448
|
+
export interface UndurableScopePurgeDb {
|
|
449
|
+
rows(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
450
|
+
exec(sql: string, params?: readonly unknown[]): Promise<number>;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* **M11** —— 把持久桶里 scope 不被 core `isValidDurableScope` 收的行(规则行与墓碑)删掉,一行一条读数。
|
|
454
|
+
*
|
|
455
|
+
* 这些行从哪来:7.119.0 及更早的本仓入口按「root 非空」收 project scope(`/v1/rules` 写口、两条导入面),引擎 7.45 也收;
|
|
456
|
+
* 引擎 7.46.0 把 project root 收窄成完整绝对路径 ⇒ `project:myproj` 一类存量行在读侧被丢(`permission.rule_row_dropped`,每跑一条),
|
|
457
|
+
* 而撤销口删不动它(引擎的写臂对这种 scope 的墓碑同样拒)。⇒ 一次性迁移,不留「撤销口对坏 scope 另开一条读法」的长期双读。
|
|
458
|
+
*
|
|
459
|
+
* 读法与写法都是**店的那一只**:每只桶先过唯一判官 {@link decodeBucketRow}(读腿用的同一只),判官整桶拒 ⇒ 这只桶**一个字节不动**,
|
|
460
|
+
* 进 `bucketsUnreadable`(桶键 + 不带行内容的拒因;读腿对它的整桶拒仍是它的属主,出路是人);判官放行 ⇒ 在那份合法状态上筛 scope,
|
|
461
|
+
* 经写臂的同一只 {@link writeBucket} 整桶 OCC 回写(`WHERE owner_key = ? AND rev = ?`,rev+1,落盘字节与指纹同写臂)—— 不碰别的桶。
|
|
462
|
+
* **硬删、不铸墓碑**:墓碑的作用是挡同步回灌,而引擎的 sync-join 对 scope 不合法的入站行本来就丢(`applySyncJoin`),
|
|
463
|
+
* 本店也不实现同步(见 `apply` 的 sync-join 臂)。
|
|
464
|
+
* 幂等:第二跑零命中零写(读不出的桶每一跑照报,直到有人处置)。
|
|
465
|
+
*/
|
|
466
|
+
export declare function purgeUndurableScopeRows(db: UndurableScopePurgeDb, dialect: "tidb" | "pg", now?: () => number): Promise<UndurableScopePurge>;
|
|
409
467
|
/** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
|
|
410
468
|
* 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
|
|
411
469
|
export interface PermissionRuleStores {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
|
|
3
|
+
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, isValidDurableScope, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
|
|
4
4
|
import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
|
|
5
5
|
import { isMissingColumnError } from "./sql-errors.js";
|
|
6
6
|
import { canonicalJson } from "../canonical-json.js";
|
|
7
7
|
import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
8
8
|
import { ensureIndexes } from "./ensure-index.js";
|
|
9
|
-
import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
|
|
9
|
+
import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, SqlJsonColumnError } from "./sql-json-column.js";
|
|
10
|
+
import { redactSecrets } from "../trace/redact.js";
|
|
10
11
|
export function writerOfSqlRuleStore(store) {
|
|
11
12
|
const w = Reflect.get(store, PERMISSION_RULE_WRITER);
|
|
12
13
|
if (w === null || typeof w !== "object")
|
|
@@ -20,7 +21,7 @@ export function writerOfSqlRuleStore(store) {
|
|
|
20
21
|
}
|
|
21
22
|
export const DurableRuleScopeSchema = z.union([
|
|
22
23
|
z.object({ kind: z.literal("global") }).strict(),
|
|
23
|
-
z.object({ kind: z.literal("project"), root: z.string()
|
|
24
|
+
z.object({ kind: z.literal("project"), root: z.string() }).strict(),
|
|
24
25
|
]);
|
|
25
26
|
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
26
27
|
const RuleAddSchema = z
|
|
@@ -57,11 +58,17 @@ const QuarantinedRuleAddRowSchema = z
|
|
|
57
58
|
function readBucketRowBehavior(raw, what, ownerKey) {
|
|
58
59
|
const behavior = readRowBehavior(raw);
|
|
59
60
|
if (behavior === undefined) {
|
|
60
|
-
throw new
|
|
61
|
-
`refusing the whole bucket rather than reading a standing deny as "no rule"`);
|
|
61
|
+
throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries a ${what} whose behavior word is outside the closed set`, `(${JSON.stringify(raw)}) — refusing the whole bucket rather than reading a standing deny as "no rule"`);
|
|
62
62
|
}
|
|
63
63
|
return behavior;
|
|
64
64
|
}
|
|
65
|
+
class BucketRefusedError extends Error {
|
|
66
|
+
verdict;
|
|
67
|
+
constructor(verdict, evidence) {
|
|
68
|
+
super(`${verdict} ${evidence}`);
|
|
69
|
+
this.verdict = verdict;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
65
72
|
const WritablePersistedRuleSchema = PersistedRuleRowSchema.extend({ behavior: z.enum(RULE_BEHAVIORS) });
|
|
66
73
|
const WritableRuleTombstoneSchema = RuleTombstoneSchema.extend({ behavior: z.enum(RULE_BEHAVIORS) });
|
|
67
74
|
const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
|
|
@@ -349,6 +356,64 @@ async function refusingUnreadableColumns(what, fn) {
|
|
|
349
356
|
throw foldJsonColumn(e, (m) => `${m} — the ${what} does not carry a readable shape; refusing the whole of it rather than report a partial one`);
|
|
350
357
|
}
|
|
351
358
|
}
|
|
359
|
+
function bucketRowColumns(dialect) {
|
|
360
|
+
return `actor, counter, rev, ${jsonColumnText(dialect, "rules_json")}, ${jsonColumnText(dialect, "tombstones_json")}, ${jsonColumnText(dialect, "quarantined_json")}, ${jsonColumnText(dialect, "observed_vector_json")}, checksum`;
|
|
361
|
+
}
|
|
362
|
+
function decodeBucketRow(ownerKey, row) {
|
|
363
|
+
const at = `${PERMISSION_RULE_TABLE} bucket ${ownerKey}`;
|
|
364
|
+
const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
|
|
365
|
+
const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
|
|
366
|
+
const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
|
|
367
|
+
const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
|
|
368
|
+
const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
|
|
369
|
+
if (String(row.checksum ?? "") !== expected) {
|
|
370
|
+
throw new BucketRefusedError(`permission_rule bucket ${ownerKey} failed its integrity check`, `(stored ${String(row.checksum)}, computed ${expected}) — refusing the whole bucket rather than reporting a partial rule set`);
|
|
371
|
+
}
|
|
372
|
+
const rules = rulesRaw.map((r) => ({ ...r, behavior: readBucketRowBehavior(r.behavior, "rule row", ownerKey) }));
|
|
373
|
+
const tombstones = tombstonesRaw.map((t) => ({ ...t, behavior: readBucketRowBehavior(t.behavior, "tombstone", ownerKey) }));
|
|
374
|
+
const quarantined = quarantinedRaw.map((q) => ({ ...q, behavior: readBucketRowBehavior(q.behavior, "quarantined row", ownerKey) }));
|
|
375
|
+
const badTombstone = tombstones.find((t) => {
|
|
376
|
+
const parsed = parseRuleText(t.rule, t.behavior);
|
|
377
|
+
return "reject" in parsed || parsed.rule.rule !== t.rule;
|
|
378
|
+
});
|
|
379
|
+
if (badTombstone !== undefined) {
|
|
380
|
+
throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically`, `(${badTombstone.rule}) — refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
|
|
381
|
+
}
|
|
382
|
+
const screened = screenRuleSyncState({ rules, tombstones });
|
|
383
|
+
if (screened.rejected.length > 0) {
|
|
384
|
+
const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
|
|
385
|
+
throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries rows the single validator refuses`, `[${names}] — refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
actor: String(row.actor ?? ""),
|
|
389
|
+
counter: Number(row.counter ?? 0),
|
|
390
|
+
rev: Number(row.rev ?? 0),
|
|
391
|
+
rules,
|
|
392
|
+
tombstones,
|
|
393
|
+
quarantined,
|
|
394
|
+
...(observedVector !== undefined ? { observedVector } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function writeBucket(exec, dialect, ownerKey, cur, next, nowMs) {
|
|
398
|
+
const writable = z.object({ rules: z.array(WritablePersistedRuleSchema), tombstones: z.array(WritableRuleTombstoneSchema) }).safeParse(next);
|
|
399
|
+
if (!writable.success) {
|
|
400
|
+
throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
|
|
401
|
+
}
|
|
402
|
+
const body = { rules: next.rules, tombstones: next.tombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
|
|
403
|
+
const enc = dialectProtocolJsonEncoder(dialect);
|
|
404
|
+
const affected = await exec(dialect === "tidb"
|
|
405
|
+
? `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`
|
|
406
|
+
: `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`, [
|
|
407
|
+
enc(next.rules, "permission_rule.rules_json"),
|
|
408
|
+
enc(next.tombstones, "permission_rule.tombstones_json"),
|
|
409
|
+
enc(cur.quarantined, "permission_rule.quarantined_json"),
|
|
410
|
+
buildRuleBucketChecksum(body),
|
|
411
|
+
nowMs,
|
|
412
|
+
ownerKey,
|
|
413
|
+
cur.rev,
|
|
414
|
+
]);
|
|
415
|
+
return affected === 1;
|
|
416
|
+
}
|
|
352
417
|
class SqlDurableRulePartition {
|
|
353
418
|
db;
|
|
354
419
|
owner;
|
|
@@ -372,47 +437,11 @@ class SqlDurableRulePartition {
|
|
|
372
437
|
return refusingUnreadableColumns(`bucket ${this.ownerKey}`, () => this.readBucketRow());
|
|
373
438
|
}
|
|
374
439
|
async readBucketRow() {
|
|
375
|
-
const { rows } = await this.db.query(this.q(`SELECT
|
|
440
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bucketRowColumns("tidb")} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT ${bucketRowColumns("pg")} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
|
|
376
441
|
const row = rows[0];
|
|
377
442
|
if (row === undefined)
|
|
378
443
|
return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
|
|
379
|
-
|
|
380
|
-
const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
|
|
381
|
-
const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
|
|
382
|
-
const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
|
|
383
|
-
const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
|
|
384
|
-
const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
|
|
385
|
-
if (String(row.checksum ?? "") !== expected) {
|
|
386
|
-
throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
|
|
387
|
-
`refusing the whole bucket rather than reporting a partial rule set`);
|
|
388
|
-
}
|
|
389
|
-
const rules = rulesRaw.map((r) => ({ ...r, behavior: readBucketRowBehavior(r.behavior, "rule row", this.ownerKey) }));
|
|
390
|
-
const tombstones = tombstonesRaw.map((t) => ({ ...t, behavior: readBucketRowBehavior(t.behavior, "tombstone", this.ownerKey) }));
|
|
391
|
-
const quarantined = quarantinedRaw.map((q) => ({ ...q, behavior: readBucketRowBehavior(q.behavior, "quarantined row", this.ownerKey) }));
|
|
392
|
-
const state = {
|
|
393
|
-
actor: String(row.actor ?? ""),
|
|
394
|
-
counter: Number(row.counter ?? 0),
|
|
395
|
-
rev: Number(row.rev ?? 0),
|
|
396
|
-
rules,
|
|
397
|
-
tombstones,
|
|
398
|
-
quarantined,
|
|
399
|
-
...(observedVector !== undefined ? { observedVector } : {}),
|
|
400
|
-
};
|
|
401
|
-
const badTombstone = tombstones.find((t) => {
|
|
402
|
-
const parsed = parseRuleText(t.rule, t.behavior);
|
|
403
|
-
return "reject" in parsed || parsed.rule.rule !== t.rule;
|
|
404
|
-
});
|
|
405
|
-
if (badTombstone !== undefined) {
|
|
406
|
-
throw new Error(`permission_rule bucket ${this.ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically (${badTombstone.rule}) — ` +
|
|
407
|
-
`refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
|
|
408
|
-
}
|
|
409
|
-
const screened = screenRuleSyncState({ rules, tombstones });
|
|
410
|
-
if (screened.rejected.length > 0) {
|
|
411
|
-
const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
|
|
412
|
-
throw new Error(`permission_rule bucket ${this.ownerKey} carries rows the single validator refuses [${names}] — ` +
|
|
413
|
-
`refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
|
|
414
|
-
}
|
|
415
|
-
return state;
|
|
444
|
+
return decodeBucketRow(this.ownerKey, row);
|
|
416
445
|
}
|
|
417
446
|
async ensureBucket() {
|
|
418
447
|
const nowMs = this.now();
|
|
@@ -500,22 +529,8 @@ class SqlDurableRulePartition {
|
|
|
500
529
|
throw new Error(`the SQL permission-rule backend was handed a write delta it does not know: ${JSON.stringify(unknown)}`);
|
|
501
530
|
}
|
|
502
531
|
}
|
|
503
|
-
const
|
|
504
|
-
if (!
|
|
505
|
-
throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
|
|
506
|
-
}
|
|
507
|
-
const body = { rules: nextRules, tombstones: nextTombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
|
|
508
|
-
const nowMs = this.now();
|
|
509
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`), [
|
|
510
|
-
this.enc(nextRules, "permission_rule.rules_json"),
|
|
511
|
-
this.enc(nextTombstones, "permission_rule.tombstones_json"),
|
|
512
|
-
this.enc(cur.quarantined, "permission_rule.quarantined_json"),
|
|
513
|
-
buildRuleBucketChecksum(body),
|
|
514
|
-
nowMs,
|
|
515
|
-
this.ownerKey,
|
|
516
|
-
opts.expectedRev,
|
|
517
|
-
]);
|
|
518
|
-
if (affected === 0) {
|
|
532
|
+
const landed = await writeBucket(async (sql, params) => (await this.db.query(sql, params)).affected, this.db.dialect, this.ownerKey, cur, { rules: nextRules, tombstones: nextTombstones }, this.now());
|
|
533
|
+
if (!landed) {
|
|
519
534
|
const again = await this.read();
|
|
520
535
|
return { conflict: true, rev: again.rev };
|
|
521
536
|
}
|
|
@@ -766,6 +781,98 @@ export class SqlRuleImportTicketStore {
|
|
|
766
781
|
}
|
|
767
782
|
export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
|
|
768
783
|
export const RULE_REAP_BATCH = 500;
|
|
784
|
+
const UNDURABLE_PURGE_PAGE = 200;
|
|
785
|
+
const UNDURABLE_PURGE_OCC_ATTEMPTS = 5;
|
|
786
|
+
export async function purgeUndurableScopeRows(db, dialect, now = Date.now) {
|
|
787
|
+
const acc = { removed: [], bucketsDeferred: 0, bucketsUnreadable: [] };
|
|
788
|
+
try {
|
|
789
|
+
await purgeAllBuckets(db, dialect, now, acc);
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
if (acc.removed.length === 0)
|
|
793
|
+
throw e;
|
|
794
|
+
throw new Error(`the one-time removal of persisted permission rules whose scope the engine refuses failed AFTER removing ${acc.removed.length} row(s) — record them before retrying: ` +
|
|
795
|
+
acc.removed.map((r) => `${r.kind} in bucket ${r.ownerKey} (${r.behavior} ${r.tool}, scope root ${JSON.stringify(r.scopeRoot)})`).join("; ") +
|
|
796
|
+
`. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
797
|
+
}
|
|
798
|
+
return acc;
|
|
799
|
+
}
|
|
800
|
+
async function purgeAllBuckets(db, dialect, now, acc) {
|
|
801
|
+
const cols = `owner_key, ${bucketRowColumns(dialect)}`;
|
|
802
|
+
let after = "";
|
|
803
|
+
for (;;) {
|
|
804
|
+
const page = await db.rows(dialect === "tidb"
|
|
805
|
+
? `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key > ? ORDER BY owner_key LIMIT ${UNDURABLE_PURGE_PAGE}`
|
|
806
|
+
: `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key > $1 ORDER BY owner_key LIMIT ${UNDURABLE_PURGE_PAGE}`, [after]);
|
|
807
|
+
for (const first of page) {
|
|
808
|
+
const ownerKey = String(first.owner_key);
|
|
809
|
+
after = ownerKey;
|
|
810
|
+
let row = first;
|
|
811
|
+
let settled = false;
|
|
812
|
+
for (let attempt = 0; attempt < UNDURABLE_PURGE_OCC_ATTEMPTS && row !== undefined && !settled; attempt++) {
|
|
813
|
+
const outcome = await purgeOneBucket(db, dialect, ownerKey, row, now);
|
|
814
|
+
switch (outcome.kind) {
|
|
815
|
+
case "purged":
|
|
816
|
+
acc.removed.push(...outcome.rows);
|
|
817
|
+
settled = true;
|
|
818
|
+
break;
|
|
819
|
+
case "unreadable":
|
|
820
|
+
acc.bucketsUnreadable.push({ ownerKey, why: outcome.why });
|
|
821
|
+
settled = true;
|
|
822
|
+
break;
|
|
823
|
+
case "clean":
|
|
824
|
+
settled = true;
|
|
825
|
+
break;
|
|
826
|
+
case "conflict":
|
|
827
|
+
row = (await db.rows(dialect === "tidb" ? `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?` : `SELECT ${cols} FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`, [ownerKey]))[0];
|
|
828
|
+
if (row === undefined)
|
|
829
|
+
settled = true;
|
|
830
|
+
break;
|
|
831
|
+
default: {
|
|
832
|
+
const unknown = outcome;
|
|
833
|
+
throw new Error(`M11: unhandled bucket outcome ${JSON.stringify(unknown)}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (!settled)
|
|
838
|
+
acc.bucketsDeferred += 1;
|
|
839
|
+
}
|
|
840
|
+
if (page.length < UNDURABLE_PURGE_PAGE)
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function purgeOneBucket(db, dialect, ownerKey, row, now) {
|
|
845
|
+
let cur;
|
|
846
|
+
try {
|
|
847
|
+
cur = decodeBucketRow(ownerKey, row);
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
return { kind: "unreadable", why: contentFreeRefusal(ownerKey, e) };
|
|
851
|
+
}
|
|
852
|
+
const badRules = cur.rules.filter((r) => !isValidDurableScope(r.scope));
|
|
853
|
+
const badTombstones = cur.tombstones.filter((t) => !isValidDurableScope(t.scope));
|
|
854
|
+
if (badRules.length === 0 && badTombstones.length === 0)
|
|
855
|
+
return { kind: "clean" };
|
|
856
|
+
const rootOf = (scope) => redactSecrets(scope.kind === "project" ? scope.root : scope.kind);
|
|
857
|
+
const rows = [
|
|
858
|
+
...badRules.map((r) => ({ ownerKey, kind: applyTombstones([r], cur.tombstones).length > 0 ? "rule" : "revoked-rule", behavior: r.behavior, tool: r.tool, scopeRoot: rootOf(r.scope) })),
|
|
859
|
+
...badTombstones.map((t) => {
|
|
860
|
+
const parsed = parseRuleText(t.rule, t.behavior);
|
|
861
|
+
if ("reject" in parsed)
|
|
862
|
+
throw new Error(`permission_rule bucket ${ownerKey}: a tombstone the bucket judge admitted does not parse here — refusing to touch the bucket`);
|
|
863
|
+
return { ownerKey, kind: "tombstone", behavior: t.behavior, tool: parsed.rule.tool, scopeRoot: rootOf(t.scope) };
|
|
864
|
+
}),
|
|
865
|
+
];
|
|
866
|
+
const landed = await writeBucket((sql, params) => db.exec(sql, params), dialect, ownerKey, cur, { rules: cur.rules.filter((r) => !badRules.includes(r)), tombstones: cur.tombstones.filter((t) => !badTombstones.includes(t)) }, now());
|
|
867
|
+
return landed ? { kind: "purged", rows } : { kind: "conflict" };
|
|
868
|
+
}
|
|
869
|
+
function contentFreeRefusal(ownerKey, e) {
|
|
870
|
+
if (e instanceof BucketRefusedError)
|
|
871
|
+
return e.verdict;
|
|
872
|
+
if (e instanceof SqlJsonColumnError)
|
|
873
|
+
return `${e.where}: ${e.problem}`;
|
|
874
|
+
return `permission_rule bucket ${ownerKey}: the bucket judge threw ${e instanceof Error ? e.name : typeof e} (its text is withheld here: it may quote the row)`;
|
|
875
|
+
}
|
|
769
876
|
export function createSqlPermissionRuleStores(db, now) {
|
|
770
877
|
const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
|
|
771
878
|
return {
|
|
@@ -152,6 +152,12 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
|
|
|
152
152
|
private absentGuardFaces;
|
|
153
153
|
private driftError;
|
|
154
154
|
private resolve;
|
|
155
|
+
/**
|
|
156
|
+
* exec / execStream 指令帧的 `cwd` —— **恒填、恒绝对**的唯一铸点(协议文档 §3.1:executor 不再有「缺席时按
|
|
157
|
+
* 隐含工作目录」的第二种读法)。调用方没给 ⇒ env 的 cwd(铸造时已由 {@link effectiveDeviceCwd} 保证绝对);
|
|
158
|
+
* 给了 ⇒ 按同一只 {@link resolve} 解成绝对。其余指令 kind 的路径参数在下发前就已是绝对路径,不带 `cwd`。
|
|
159
|
+
*/
|
|
160
|
+
private instructionCwd;
|
|
155
161
|
absolutePath(p: string): Promise<Result<string, FileError>>;
|
|
156
162
|
joinPath(parts: string[]): Promise<Result<string, FileError>>;
|
|
157
163
|
workspaceHandle(): WorkspaceHandle;
|
|
@@ -321,8 +327,13 @@ export declare function deviceExecutionEnvFactory(deps: DeviceEnvFactoryDeps): E
|
|
|
321
327
|
* §4.3.3 的 cwd 优先序 —— 与 host 腿的 `effectiveHostWorkspace` **同源**:
|
|
322
328
|
* ① 本 session 显式注册的 cwd(`deviceCwdHonored` 闸下由 resolve-spec 写);
|
|
323
329
|
* ② `ctx.parentCwd`(委派子代:自己没注册过 cwd,继承父的工作目录 —— 少了它子代会丢工作目录);
|
|
324
|
-
* ③
|
|
330
|
+
* ③ 设备入籍时登记并落库的 `workspaceRoot`(缺 cwd 时的解析基准,有明确 wire 契约,不是「executor
|
|
325
331
|
* 默认根」这种空话)。
|
|
332
|
+
*
|
|
333
|
+
* 🔴 **这一只解析源同时守它的后置条件:结果必是 POSIX 绝对路径,否则响亮抛。** 协议文档对 executor 的承诺是
|
|
334
|
+
* 「exec / execStream 的指令帧恒带绝对 `cwd`」—— 不带或带相对的,都会把「用哪个目录」这件事留给 executor
|
|
335
|
+
* 自己猜(同一个事实两种读法)。判据是入籍口那**同一只**谓词 `isDeviceAbsolutePath`(`device-store.ts`):入籍口用它拒相对的
|
|
336
|
+
* `workspaceRoot`;这里的判留作存量行守卫(入籍口收紧之前落库的相对根)并守另两个来源 —— 委派链的父 cwd 来自引擎,不经入籍口。
|
|
326
337
|
*/
|
|
327
338
|
export declare function effectiveDeviceCwd(sessionCwd: string | undefined, parentCwd: string | undefined, row: Pick<DeviceRow, "workspaceRoot">): string;
|
|
328
339
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { ExecutionError, FileError, RemoteExecutionError, } from "@sema-agent/core";
|
|
3
|
-
import { DEVICE_ERROR_CODES, } from "../device-store.js";
|
|
3
|
+
import { DEVICE_ERROR_CODES, isDeviceAbsolutePath, } from "../device-store.js";
|
|
4
4
|
export const DEVICE_PROVIDER = "device";
|
|
5
5
|
const ok = (value) => ({ ok: true, value });
|
|
6
6
|
const STREAM_PENDING_MAX_BYTES = 2 * 1024 * 1024;
|
|
@@ -166,7 +166,10 @@ export class RemoteDeviceExecutionEnv {
|
|
|
166
166
|
"performing a NON-atomic write here is exactly the silent downgrade guarded writes exist to prevent (#157). Refusing; the per-task factory mints a correctly-shaped env on the next task.", absPath);
|
|
167
167
|
}
|
|
168
168
|
resolve(p) {
|
|
169
|
-
return
|
|
169
|
+
return isDeviceAbsolutePath(p) ? posix.normalize(p) : posix.normalize(posix.join(this.cwd, p));
|
|
170
|
+
}
|
|
171
|
+
instructionCwd(requested) {
|
|
172
|
+
return requested === undefined ? this.cwd : this.resolve(requested);
|
|
170
173
|
}
|
|
171
174
|
async absolutePath(p) {
|
|
172
175
|
return ok(this.resolve(p));
|
|
@@ -354,7 +357,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
354
357
|
const res = await this.dispatch("exec", { command }, {
|
|
355
358
|
timeoutMs: this.timeoutMsFor(options?.timeout),
|
|
356
359
|
...(options?.abortSignal === undefined ? {} : { signal: options.abortSignal }),
|
|
357
|
-
cwd:
|
|
360
|
+
cwd: this.instructionCwd(options?.cwd),
|
|
358
361
|
...(mergedEnv(this.cfg.shellEnv, options?.env) === undefined ? {} : { shellEnv: mergedEnv(this.cfg.shellEnv, options?.env) }),
|
|
359
362
|
onChunk: (c) => {
|
|
360
363
|
if (c.kind === "stdout") {
|
|
@@ -430,7 +433,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
430
433
|
const settled = this.dispatch("execStream", { command }, {
|
|
431
434
|
timeoutMs: this.timeoutMsFor(options?.timeout),
|
|
432
435
|
signal: inner.signal,
|
|
433
|
-
cwd:
|
|
436
|
+
cwd: this.instructionCwd(options?.cwd),
|
|
434
437
|
...(mergedEnv(this.cfg.shellEnv, options?.env) === undefined ? {} : { shellEnv: mergedEnv(this.cfg.shellEnv, options?.env) }),
|
|
435
438
|
onChunk: (c) => {
|
|
436
439
|
if (!accepting) {
|
|
@@ -811,6 +814,11 @@ export function deviceExecutionEnvFactory(deps) {
|
|
|
811
814
|
};
|
|
812
815
|
}
|
|
813
816
|
export function effectiveDeviceCwd(sessionCwd, parentCwd, row) {
|
|
814
|
-
|
|
817
|
+
const [source, cwd] = sessionCwd !== undefined ? ["session cwd", sessionCwd] : parentCwd !== undefined ? ["parent cwd", parentCwd] : ["device workspaceRoot", row.workspaceRoot];
|
|
818
|
+
if (!isDeviceAbsolutePath(cwd)) {
|
|
819
|
+
throw new Error(`device lane: the effective working directory (${source}) ${JSON.stringify(cwd)} is not an absolute path — every exec / execStream instruction carries an absolute \`cwd\`, ` +
|
|
820
|
+
"and the executor must never be left to guess a base directory. Fix the source named above (a device's workspaceRoot is fixed at enrollment: enroll again with an absolute path)");
|
|
821
|
+
}
|
|
822
|
+
return cwd;
|
|
815
823
|
}
|
|
816
824
|
//# sourceMappingURL=remote-env-device.js.map
|
|
@@ -26,8 +26,11 @@ type Caps = SchedulerCapability["schedulerCapabilities"];
|
|
|
26
26
|
* cron via core `isValidCronExpr` (RCE-safe, no shell-exec); delay/at bounded by minDelaySec + horizon.
|
|
27
27
|
*/
|
|
28
28
|
export declare function validateIntent(intent: ScheduledIntent, caps: Caps, nowMs: number): SchedulerError | null;
|
|
29
|
-
/** The default shared store path:
|
|
30
|
-
|
|
29
|
+
/** The default shared store path: the configuration root (the `agentConfigDir` seat `loadConfig` minted from `SEMA_CONFIG_DIR`, else `~/.sema`),
|
|
30
|
+
* file `scheduled_tasks.json` (server + shell agree). Reads the seat, never `process.env` at call time. */
|
|
31
|
+
export declare function defaultSchedulerStorePath(config: {
|
|
32
|
+
readonly agentConfigDir?: string;
|
|
33
|
+
}): string;
|
|
31
34
|
/**
|
|
32
35
|
* File-backed SchedulerCapability (durable across daemon restarts). Store = a single JSON file (atomic write via
|
|
33
36
|
* the registry-core /node binding). The resident shell daemon reads the same store to fire intents. `now`
|
|
@@ -37,8 +40,9 @@ export declare class FileSchedulerBackend implements SchedulerCapability {
|
|
|
37
40
|
readonly schedulerCapabilities: Caps;
|
|
38
41
|
private readonly storePath;
|
|
39
42
|
private readonly now;
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
/** `storePath` 必填:路径是装配层从配置定的事实(`SCHEDULER_STORE_PATH` ?? {@link defaultSchedulerStorePath}),本类不自己找缺省。 */
|
|
44
|
+
constructor(opts: {
|
|
45
|
+
storePath: string;
|
|
42
46
|
caps?: Partial<Caps>;
|
|
43
47
|
now?: () => number;
|
|
44
48
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { randomBytes, createHash } from "node:crypto";
|
|
4
3
|
import { SchedulerError, isValidCronExpr } from "@sema-agent/core";
|
|
5
4
|
import { loadSchedulerStore, saveSchedulerStore } from "@sema-agent/settings-schema/node";
|
|
5
|
+
import { configRootOf } from "../sema-config-dir.js";
|
|
6
6
|
const ok = (value) => ({ ok: true, value });
|
|
7
7
|
const err = (error) => ({ ok: false, error });
|
|
8
8
|
export const DEFAULT_SCHEDULER_CAPS = {
|
|
@@ -59,16 +59,16 @@ function computeNextRunMs(when, nowMs) {
|
|
|
59
59
|
return nowMs + when.delaySec * 1000;
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
-
export function defaultSchedulerStorePath() {
|
|
63
|
-
return join(
|
|
62
|
+
export function defaultSchedulerStorePath(config) {
|
|
63
|
+
return join(configRootOf(config), "scheduled_tasks.json");
|
|
64
64
|
}
|
|
65
65
|
export class FileSchedulerBackend {
|
|
66
66
|
schedulerCapabilities;
|
|
67
67
|
storePath;
|
|
68
68
|
now;
|
|
69
|
-
constructor(opts
|
|
69
|
+
constructor(opts) {
|
|
70
70
|
this.schedulerCapabilities = { ...DEFAULT_SCHEDULER_CAPS, ...(opts.caps ?? {}) };
|
|
71
|
-
this.storePath = opts.storePath
|
|
71
|
+
this.storePath = opts.storePath;
|
|
72
72
|
this.now = opts.now ?? (() => Date.now());
|
|
73
73
|
}
|
|
74
74
|
load() {
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { compileAllowList } from "@sema-agent/core";
|
|
23
23
|
import { emptyToolSegmentOf } from "./namespaced-rule-name.js";
|
|
24
|
-
export declare function presentRuleEntry(entry: string): string;
|
|
25
24
|
/** ① 形:字符串数组(空串也是字符串 —— 引擎把它读成 `grammar` 并通告 / 拒,见 ③)。 */
|
|
26
25
|
export declare const ruleEntryListShapeOk: (v: unknown) => v is readonly string[];
|
|
27
26
|
/**
|
package/dist/rule-entry-list.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { compileAllowList } from "@sema-agent/core";
|
|
2
|
+
import { displayListEntry } from "./list-entry-display.js";
|
|
2
3
|
import { describeEmptyToolSegment, emptyToolSegmentOf, NO_DECLARED_TOOL_NAMES, ruleEntryToolName } from "./namespaced-rule-name.js";
|
|
3
|
-
import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
|
|
4
|
-
const PRESENTED_ENTRY_MAX = 160;
|
|
5
|
-
export function presentRuleEntry(entry) {
|
|
6
|
-
const clean = entry.replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
7
|
-
return clean.length <= PRESENTED_ENTRY_MAX ? clean : `${clean.slice(0, PRESENTED_ENTRY_MAX)}…(${entry.length} chars)`;
|
|
8
|
-
}
|
|
9
4
|
export const ruleEntryListShapeOk = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
10
5
|
export const MAX_ALLOW_LAYER_ENTRIES = 1_000;
|
|
11
6
|
export function describeAllowLayerOverCap(field, count) {
|
|
@@ -26,14 +21,14 @@ export function emptyToolSegmentEntryOf(list) {
|
|
|
26
21
|
return undefined;
|
|
27
22
|
}
|
|
28
23
|
export function describeEmptyToolSegmentEntry(field, u) {
|
|
29
|
-
return `${field} names
|
|
24
|
+
return `${field} names ${displayListEntry(u.entry)}, which can never match a tool (${describeEmptyToolSegment(u.ns)})`;
|
|
30
25
|
}
|
|
31
26
|
export function inertRuleEntriesOf(list, behavior) {
|
|
32
27
|
return compileAllowList(list, behavior, NO_DECLARED_TOOL_NAMES).inert;
|
|
33
28
|
}
|
|
34
29
|
export function inertRuleEntryGuidance(i) {
|
|
35
30
|
const reason = i.reason;
|
|
36
|
-
const write = i.currentName !== undefined ? ` — write
|
|
31
|
+
const write = i.currentName !== undefined ? ` — write ${displayListEntry(i.currentName)}` : "";
|
|
37
32
|
switch (reason) {
|
|
38
33
|
case "tool_name":
|
|
39
34
|
return `the tool name can never match a mounted tool (${i.verdict ?? "stale"})${write}`;
|
|
@@ -58,7 +53,7 @@ export function inertRuleEntryGuidance(i) {
|
|
|
58
53
|
}
|
|
59
54
|
}
|
|
60
55
|
export function describeInertRuleEntries(field, inert) {
|
|
61
|
-
return inert.map((i) => `${field} entry
|
|
56
|
+
return inert.map((i) => `${field} entry ${displayListEntry(i.entry)} reads as nothing (${i.reason}): ${inertRuleEntryGuidance(i)}`).join("; ");
|
|
62
57
|
}
|
|
63
58
|
const isPlainRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
64
59
|
function skillDenyInertSentence(skill) {
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -163,7 +163,7 @@ export interface LocalImportRuleKey {
|
|
|
163
163
|
* 它在端点上先判、有自己的整句文案,而且判完就把类型收窄掉(`permission-rule-vocab.ts` 的
|
|
164
164
|
* `DIRECT_RULE_WRITE_ADMITS`)⇒ 放宽态根本到不了本表所服务的那段代码。 */
|
|
165
165
|
export type LocalImportRefusalCode =
|
|
166
|
-
/** scope
|
|
166
|
+
/** scope 串读不出形,或读出来的 scope 引擎的 durable 谓词不收(既不是 `global` 也不是 `project:<绝对 root>`;判据 = {@link parseRuleScope})。 */
|
|
167
167
|
"scope_unreadable"
|
|
168
168
|
/** scope 的 project root 里有 bidi 控制符([ref]② Trojan Source 族):人看到的射程与机器判据用的
|
|
169
169
|
* 射程不是同一串 —— 一条规则的**作用范围**因此可以被伪装。 */
|
|
@@ -480,7 +480,7 @@ export declare function serializeRuleScope(scope: RuleScope): string;
|
|
|
480
480
|
* 各挑各的」这件事本身,不是把某个数字调大一点)。
|
|
481
481
|
*
|
|
482
482
|
* 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = `isValidCwd` 的 {@link MAX_CWD_CHARS}),
|
|
483
|
-
* core
|
|
483
|
+
* core 与两个店都不设 root 长度上限 ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
|
|
484
484
|
* `scope: max(1088)`,且 safeParse 排在 `parseRuleScope`/权限判据**之前** ⇒ 超限 root 的规则
|
|
485
485
|
* `GET /v1/rules` **列得出**、按同一串 DELETE **恒 400**。全仓只有一个 `lane.removeRule` 调用点(直删
|
|
486
486
|
* 店行是无墓碑硬删、会被 sync 的 join 复活),所以那是**一条已列出的常驻放行规则在受支持接口上不可撤**;
|
|
@@ -502,8 +502,17 @@ export type DurableRuleScope = Exclude<RuleScope, {
|
|
|
502
502
|
kind: "session";
|
|
503
503
|
}>;
|
|
504
504
|
/** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
|
|
505
|
-
* 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。
|
|
505
|
+
* 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。
|
|
506
|
+
*
|
|
507
|
+
* 🔴 **收不收只有一只判据:core 的 `isValidDurableScope`**(引擎 7.46.0 起 project root 必须是完整绝对路径:`/…`、`X:\…` /
|
|
508
|
+
* `X:/…`、`\\host\…`)。四个 wire 入口(`/v1/rules` 写 / 撤 / 列举过滤、local-import 的行)都经这里;cc-import 的层 root 调同一只
|
|
509
|
+
* 谓词(`http/routes/rules.ts`)。修前这里手抄了一份「root 非空」:`project:myproj` 本仓收、引擎写臂拒(写口答 503「再发一次」——
|
|
510
|
+
* 客户端会无限重试)、引擎读侧丢(撤销口删不动),一条规则在两侧是两种命运。存量的那种行由一次性迁移 M11 删
|
|
511
|
+
* (`plugins/one-time-migrations.ts` 登记表)。 */
|
|
506
512
|
export declare function parseRuleScope(text: string): DurableRuleScope | undefined;
|
|
513
|
+
/** 引擎不收的 scope(或 cc-import 层 root)的**唯一一句**拒句:写 / 撤 / 列举过滤三个路由口、cc-import 预览的层 root、local-import 的成员拒因
|
|
514
|
+
* `scope_unreadable` 都出站这一只(各口的码照旧各自的),不在读点各抄一份。 */
|
|
515
|
+
export declare const RULE_SCOPE_REFUSAL_SENTENCE = "scope must be 'global' or 'project:<root>' with an absolute root";
|
|
507
516
|
/** 一条规则在 wire 上的行 = 一个逻辑 (rule, scope) 对([ref] v2 §6 F5)。
|
|
508
517
|
* `adds` **如实上 wire**、不折叠成单值:同一条规则可以被批准过多次(不同 dot、不同来源、不同时刻),
|
|
509
518
|
* 折成「一个 addedAt」会让「这条规则是我导入的还是我点过卡的」在治理面上不可分。 */
|