@sema-agent/server 7.118.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 +139 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +26 -13
- package/deploy/sema-up/README.md +2 -0
- package/deploy/sema-up/chart/templates/minio.yaml +2 -1
- package/deploy/sema-up/chart/templates/server-v4pro.yaml +1 -0
- package/deploy/sema-up/chart/templates/server.yaml +1 -0
- package/deploy/sema-up/chart/values.yaml +7 -1
- package/deploy/sema-up/cluster-up.sh +20 -8
- package/deploy/sema-up/kube-up.sh +29 -16
- package/deploy/sema-up/lib/buckets.sh +21 -0
- package/deploy/sema-up/lib/health.sh +33 -0
- package/deploy/sema-up/sema-up.sh +14 -9
- package/deploy/sema-up/smoke.sh +16 -6
- package/deploy/sema-up/stack/compose.yaml +1 -1
- package/deploy/sema-up/stack/sema-stack-cluster.yaml +4 -1
- package/dist/approval-ask-audit-store.js +2 -7
- package/dist/approval-card.d.ts +5 -5
- package/dist/approval.d.ts +51 -5
- package/dist/approval.js +76 -13
- package/dist/audit.d.ts +12 -1
- package/dist/audit.js +62 -2
- package/dist/auto-mode-face.d.ts +2 -3
- package/dist/bake-runner/main.js +1 -6
- package/dist/bake-runner/protocol.d.ts +18 -0
- package/dist/bake-runner/protocol.js +22 -2
- package/dist/bake-runner/runner.d.ts +12 -8
- package/dist/bake-runner/runner.js +5 -9
- package/dist/boot/config-center.js +6 -6
- package/dist/boot/coordinators.js +4 -1
- package/dist/boot/device-lane.d.ts +3 -3
- package/dist/boot/device-lane.js +1 -2
- 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/leader.d.ts +4 -4
- package/dist/boot/leader.js +1 -2
- package/dist/boot/parked-revive-gate.d.ts +5 -1
- package/dist/boot/parked-revive-gate.js +8 -2
- package/dist/boot/resolve-spec.js +7 -4
- 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/boot/stage-07-capability-layer.js +5 -1
- package/dist/boot/stage-10-http-server.js +3 -2
- package/dist/boot/stores.js +12 -7
- package/dist/capabilities/builtin-tools.d.ts +31 -8
- package/dist/capabilities/builtin-tools.js +22 -1
- package/dist/capabilities/center-plugins.d.ts +2 -0
- package/dist/capabilities/center-plugins.js +24 -6
- package/dist/capabilities/code-review-council.js +8 -11
- package/dist/capabilities/scenarios.d.ts +42 -13
- package/dist/capabilities/scenarios.js +36 -9
- 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 +5 -5
- 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 +11 -11
- package/dist/deployment-governance.d.ts +8 -3
- package/dist/deployment-governance.js +7 -4
- 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/host-decision.d.ts +52 -27
- package/dist/host-decision.js +14 -7
- package/dist/http/admission.js +6 -6
- package/dist/http/resume-legs.js +6 -1
- package/dist/http/routes/approvals-assistant.d.ts +25 -3
- package/dist/http/routes/approvals-assistant.js +9 -5
- package/dist/http/routes/capabilities.js +5 -3
- package/dist/http/routes/devices.js +3 -3
- package/dist/http/routes/memory-origin.d.ts +1 -1
- package/dist/http/routes/rules.js +13 -6
- package/dist/http/routes/runs.js +11 -5
- package/dist/http/routes/sessions.js +20 -16
- package/dist/http/wire-types.d.ts +12 -13
- package/dist/images/manifest.d.ts +7 -1
- package/dist/images/manifest.js +18 -16
- package/dist/list-entry-display.d.ts +14 -0
- package/dist/list-entry-display.js +14 -0
- package/dist/memory-store-refusal.d.ts +7 -12
- package/dist/memory-store-refusal.js +4 -5
- package/dist/namespaced-rule-name.d.ts +4 -3
- package/dist/namespaced-rule-name.js +2 -6
- package/dist/observability/fail-open.d.ts +9 -1
- package/dist/observability/fail-open.js +9 -1
- package/dist/operator-ask.d.ts +7 -6
- package/dist/plugins/approval-exemption-store.d.ts +4 -0
- package/dist/plugins/approval-exemption-store.js +1 -0
- package/dist/plugins/blob-backend.d.ts +13 -0
- package/dist/plugins/blob-backend.js +26 -0
- package/dist/plugins/local-session-store.d.ts +78 -47
- package/dist/plugins/local-session-store.js +152 -122
- package/dist/plugins/one-time-migrations.d.ts +14 -0
- package/dist/plugins/one-time-migrations.js +90 -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/plugins/snapshot-bucket-presence.d.ts +36 -0
- package/dist/plugins/snapshot-bucket-presence.js +40 -0
- package/dist/plugins/store-backend.d.ts +17 -6
- package/dist/plugins/store-backend.js +46 -16
- package/dist/plugins/store-open-wait.d.ts +33 -0
- package/dist/plugins/store-open-wait.js +73 -0
- package/dist/rule-entry-list.d.ts +0 -1
- package/dist/rule-entry-list.js +5 -10
- package/dist/rules-consent.d.ts +12 -3
- package/dist/rules-consent.js +5 -4
- package/dist/run-local.js +6 -3
- package/dist/security.d.ts +5 -3
- package/dist/security.js +4 -0
- package/dist/sema-config-dir.d.ts +19 -0
- package/dist/sema-config-dir.js +33 -0
- package/dist/session-policy-wire.js +10 -14
- package/dist/spec-fields.d.ts +7 -2
- package/dist/store-posture.d.ts +14 -6
- package/dist/store-posture.js +7 -4
- package/dist/task-settings.d.ts +30 -58
- package/dist/task-settings.js +6 -18
- package/dist/tool-approval.d.ts +97 -43
- package/dist/tool-approval.js +120 -31
- package/dist/trace/engine-notice-wire.d.ts +6 -4
- package/dist/trace/engine-notice-wire.js +16 -9
- package/dist/trace/redact.d.ts +6 -0
- package/dist/trace/redact.js +9 -9
- package/dist/trace/self-minted-wire.d.ts +15 -1
- package/dist/trace/self-minted-wire.js +2 -0
- package/dist/user-input-ledger.d.ts +19 -15
- package/dist/user-input-ledger.js +12 -20
- 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() {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对象存储配了快照桶却**不存在** = 部署错 —— boot 期核**一次**,缺 ⇒ 响亮拒启(不是每一拍 sweep 一条静默的 404)。
|
|
3
|
+
*
|
|
4
|
+
* ## 病
|
|
5
|
+
* `SESSION_SNAPSHOT_BUCKET`(缺省 `session-snapshots`)是 snapshot-BLOB offload 与 SendUserFile 私有 presigned 轨 / 沙箱 staging 的桶。server 不建桶
|
|
6
|
+
* (`docs/DEPLOY-PREREQS.md`「对象存储桶」),sema-up 的建桶清单又漏过它 ⇒ 起服绿,之后每一次对象写、每一拍孤儿 sweep 的列举(每分钟)都是
|
|
7
|
+
* 404 NoSuchBucket —— 运维读到的是一串「失败的请求」,不是「桶没建」。
|
|
8
|
+
*
|
|
9
|
+
* ## 形(一条规则,不加特例)
|
|
10
|
+
* · 核的是 {@link MinioBlobBackend.probeBucket}(presigned 列举 `max-keys=1`);**只有** 404 + `NoSuchBucket` 是「缺」,其余证明不了的形(连不上 / 403 / 5xx / 没有
|
|
11
|
+
* NoSuchBucket 的 404)⇒ **不拒**、照常起服,走登记过的 F 类兜底口 `recordFailOpen("server.snapshot-bucket.presence-unproven")`(计数 + 探针行 + 记录器的一次性 warn;
|
|
12
|
+
* 三问写在该 tag 的 note 里)—— 拒启要有证据,没有证据只留痕;
|
|
13
|
+
* · 缺 = 配置好的店资源还没就绪,与「库连不上」同一类处境:在 `STORE_LOCK_WAIT_MS` 窗内重试({@link openWithinWait},同一只窗、同一只节拍、同一条窗尾规则 —— 编排里
|
|
14
|
+
* 建桶晚于 server 起是常态),到窗时手上有缺桶的证据 ⇒ 拒启,拒句点名桶名、`MINIO_ENDPOINT` 的显示安全形(`buildDisplaySafeUrl` 的产物:userinfo 落地、
|
|
15
|
+
* query 值成记号,不带密钥)与出路;探针从头到尾没答过(每一次都挂到窗尾)⇒ 同样是「证明不了」,走上面那只兜底口、照常起服。
|
|
16
|
+
* · 只核**快照桶**(`config.snapshotBlobStore.bucket`;SendUserFile 的私桶缺省同一只);公桶 `sema-public` 是运维另配的匿名桶,不在本核之内。
|
|
17
|
+
*/
|
|
18
|
+
import type { ServiceConfig } from "../config-types.js";
|
|
19
|
+
import { type StoreOpenWaitClock } from "./store-open-wait.js";
|
|
20
|
+
type SnapshotBlobStoreConfig = NonNullable<ServiceConfig["snapshotBlobStore"]>;
|
|
21
|
+
/** boot 期单次探针的上界(ms):黑洞端点(丢包不拒连)不许把起服拖到对象存储 IO 的 120s;到点 ⇒ `unknown`(证明不了缺桶 ⇒ 不拒、报一次)。 */
|
|
22
|
+
export declare const SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS = 10000;
|
|
23
|
+
/** 缺桶的拒启句(唯一铸点)。 */
|
|
24
|
+
/** @param endpoint `MINIO_ENDPOINT` 的**显示安全形**(调用方过 {@link buildDisplaySafeUrl};本函数不碰原串)。 */
|
|
25
|
+
export declare function buildSnapshotBucketRefusal(bucket: string, endpoint: string, waitedMs: number): string;
|
|
26
|
+
/** 探针到窗尾一次都没答过时,兜底口 detail 里的成因(证明不了缺桶的一形)。 */
|
|
27
|
+
export declare const SNAPSHOT_BUCKET_PROBE_PENDING = "probe still pending when the window ended";
|
|
28
|
+
/**
|
|
29
|
+
* boot 期核一次快照桶(`boot/stores.ts`)。缺桶 ⇒ 窗内重试、到窗拒启;证明不了 ⇒ 兜底口留痕、照常起服。
|
|
30
|
+
* @param waitMs `config.storeLockWaitMs`。`0` ⇒ 只核一次(缺就当场拒)。
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureSnapshotBucket(store: SnapshotBlobStoreConfig, waitMs: number, logger: {
|
|
33
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
34
|
+
}, clock?: StoreOpenWaitClock): Promise<void>;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=snapshot-bucket-presence.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildDisplaySafeUrl } from "../trace/redact.js";
|
|
2
|
+
import { MinioBlobBackend } from "./blob-backend.js";
|
|
3
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
4
|
+
import { isStoreOpenWindowExpired, openWithinWait } from "./store-open-wait.js";
|
|
5
|
+
export const SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS = 10_000;
|
|
6
|
+
class SnapshotBucketMissing extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("snapshot bucket missing");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function buildSnapshotBucketRefusal(bucket, endpoint, waitedMs) {
|
|
12
|
+
const waited = waitedMs > 0 ? `after retrying for ${waitedMs}ms (STORE_LOCK_WAIT_MS)` : "on the first check (STORE_LOCK_WAIT_MS=0: no wait)";
|
|
13
|
+
return (`Object storage is configured (MINIO_ENDPOINT → ${endpoint}) but the snapshot bucket "${bucket}" (SESSION_SNAPSHOT_BUCKET; default session-snapshots) does not exist there ${waited} — refusing to boot: ` +
|
|
14
|
+
`the server never creates buckets, so every snapshot-offload write and SendUserFile private-track request would answer 404 NoSuchBucket (and the orphan sweep would log one every minute, forever). ` +
|
|
15
|
+
`Next: create the bucket (mc mb <alias>/${bucket}; this version's sema-up scripts create it — re-run them on an existing sema-up deployment), or set SESSION_SNAPSHOT_BUCKET to a bucket that exists.`);
|
|
16
|
+
}
|
|
17
|
+
export const SNAPSHOT_BUCKET_PROBE_PENDING = "probe still pending when the window ended";
|
|
18
|
+
export async function ensureSnapshotBucket(store, waitMs, logger, clock) {
|
|
19
|
+
const probe = new MinioBlobBackend({ endpoint: store.endpoint, bucket: store.bucket, accessKey: store.accessKey, secretKey: store.secretKey, ...(store.region ? { region: store.region } : {}), ...(store.keyPrefix ? { keyPrefix: store.keyPrefix } : {}) });
|
|
20
|
+
let verdict;
|
|
21
|
+
try {
|
|
22
|
+
verdict = await openWithinWait(async () => {
|
|
23
|
+
const r = await probe.probeBucket(SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS);
|
|
24
|
+
if (r.state === "missing")
|
|
25
|
+
throw new SnapshotBucketMissing();
|
|
26
|
+
return r;
|
|
27
|
+
}, waitMs, (w) => logger.warn("snapshot_bucket_wait", { bucket: store.bucket, endpoint: buildDisplaySafeUrl(store.endpoint), waitMs: w.waitMs, error: w.error }), clock);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof SnapshotBucketMissing)
|
|
31
|
+
throw new Error(buildSnapshotBucketRefusal(store.bucket, buildDisplaySafeUrl(store.endpoint), waitMs), { cause: err });
|
|
32
|
+
if (!isStoreOpenWindowExpired(err))
|
|
33
|
+
throw err;
|
|
34
|
+
verdict = { state: "unknown", detail: SNAPSHOT_BUCKET_PROBE_PENDING };
|
|
35
|
+
}
|
|
36
|
+
if (verdict.state === "unknown") {
|
|
37
|
+
recordFailOpen("server.snapshot-bucket.presence-unproven", `bucket=${store.bucket} endpoint=${buildDisplaySafeUrl(store.endpoint)} ${verdict.detail}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=snapshot-bucket-presence.js.map
|