@sema-agent/server 7.50.0 → 7.51.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/dist/boot/device-lane.d.ts +79 -0
- package/dist/boot/device-lane.js +63 -0
- package/dist/boot/execution-env.d.ts +48 -2
- package/dist/boot/execution-env.js +57 -5
- package/dist/boot/resolve-spec.d.ts +3 -0
- package/dist/boot/resolve-spec.js +12 -9
- package/dist/boot/session-faces.d.ts +5 -0
- package/dist/boot/session-faces.js +4 -1
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +3 -1
- package/dist/boot/stores.js +9 -0
- package/dist/config-center/types.d.ts +10 -3
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +18 -0
- package/dist/config-types.d.ts +12 -0
- package/dist/config.js +46 -9
- package/dist/device-enrollment.d.ts +125 -0
- package/dist/device-enrollment.js +156 -0
- package/dist/device-store.d.ts +385 -0
- package/dist/device-store.js +407 -0
- package/dist/device-ws-hub.d.ts +182 -0
- package/dist/device-ws-hub.js +1012 -0
- package/dist/device-ws-protocol.d.ts +429 -0
- package/dist/device-ws-protocol.js +464 -0
- package/dist/env-facts.d.ts +4 -1
- package/dist/env-facts.js +1 -0
- package/dist/execution-lane-caps.d.ts +152 -0
- package/dist/execution-lane-caps.js +166 -0
- package/dist/http/routes/capabilities.js +7 -2
- package/dist/http/server.d.ts +14 -0
- package/dist/http/server.js +4 -1
- package/dist/leader/wire.js +4 -2
- package/dist/main.js +10 -3
- package/dist/orchestration/hardened-vm-runner.d.ts +7 -0
- package/dist/orchestration/hardened-vm-runner.js +11 -1
- package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
- package/dist/plugins/device-store-sql.d.ts +130 -0
- package/dist/plugins/device-store-sql.js +574 -0
- package/dist/plugins/remote-env-device.d.ts +271 -0
- package/dist/plugins/remote-env-device.js +727 -0
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/store-backend.d.ts +9 -0
- package/dist/plugins/store-backend.js +3 -0
- package/dist/task-cwd.d.ts +25 -0
- package/dist/task-cwd.js +3 -0
- package/dist/task-settings.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/158 A10 分段 —— **device lane 的装配件**(device-executor-lane-v2 §4.6/§4.7/§5,车A-4)。
|
|
3
|
+
*
|
|
4
|
+
* 一处建齐三件活对象:**店**(四表双库的 `DeviceStore`)→ **准入链**(`DeviceEnrollment`,含跨副本
|
|
5
|
+
* 限速座)→ **WS 汇聚端**(`DeviceWsHub`)。三件的依赖是单向的(hub 吃 store+enrollment),所以它们
|
|
6
|
+
* 必须在同一处按序建 —— 拆到三处装配点会让「hub 拿到的是不是同一只店」变成一个要读三个文件才答得出
|
|
7
|
+
* 的问题(而答错的后果是 presence 与租约各写各的)。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **位置即契约**:必须在 `openStores`(拿 backend)之后、`createExecutionEnv`(第七臂要这三件)之前
|
|
10
|
+
* 调用;`attach(server)` 则要等 `createHttpServer` 造出裸 `http.Server` 之后(main.ts 的顺序)。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 本模块**不**做车道选择:`REMOTE_EXEC=device` 与否由调用方判(main.ts)。它只回答一件事 ——
|
|
13
|
+
* 「这台机器上,device lane 的三件装配得起来吗」。装不起来就返回 `undefined`,由
|
|
14
|
+
* {@link assertDeviceLaneWired} 响亮拒启(#157:执行车道上没有静默降级臂)。
|
|
15
|
+
*/
|
|
16
|
+
import { type DeviceEnrollRateLimiter, type DeviceEnrollment } from "../device-enrollment.js";
|
|
17
|
+
import type { DeviceStore } from "../device-store.js";
|
|
18
|
+
import { type DeviceWsHub } from "../device-ws-hub.js";
|
|
19
|
+
import type { ServiceConfig } from "../config.js";
|
|
20
|
+
import type { Logger } from "../observability/logger.js";
|
|
21
|
+
import type { Metrics } from "../observability/metrics.js";
|
|
22
|
+
import type { RateGate } from "../observability/rate-limit.js";
|
|
23
|
+
import type { StoreBackend } from "../plugins/store-backend.js";
|
|
24
|
+
export interface DeviceLaneCtx {
|
|
25
|
+
config: ServiceConfig;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
metrics: Metrics;
|
|
28
|
+
backend: StoreBackend | undefined;
|
|
29
|
+
/** 本副本 id(写进连接租约行 —— §4.6 的跨副本排障面)。 */
|
|
30
|
+
replicaId: string;
|
|
31
|
+
/**
|
|
32
|
+
* 既有的**跨副本** RateGate(SQL 后端形 = `TiDBRateLimiter`/`PgRateLimiter`;单副本形 = 进程内
|
|
33
|
+
* `RateLimiter`)。A-2 的移交义务③:enrollment 的限速不自建第二套计数面,适配这一只。
|
|
34
|
+
*
|
|
35
|
+
* 🔴 传的是**取值器**不是值:那一只建在 main.ts 的下游(它要 `counterDegradeHook`),而 device lane
|
|
36
|
+
* 必须建在 `createExecutionEnv` 之前 —— 顺序对不上。取值器让「谁先建」不再是正确性问题:boot 期
|
|
37
|
+
* 不可能有 enroll 请求,取不到 ⇒ 座**抛**(fail-closed,enrollment 翻成 429),不是放行。
|
|
38
|
+
*/
|
|
39
|
+
rateGate?: (() => RateGate | undefined) | undefined;
|
|
40
|
+
}
|
|
41
|
+
export interface DeviceLane {
|
|
42
|
+
store: DeviceStore;
|
|
43
|
+
enrollment: DeviceEnrollment;
|
|
44
|
+
hub: DeviceWsHub;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 把既有的**同步** {@link RateGate} 适配成 enrollment 要的异步座(A-2 移交义务③)。
|
|
48
|
+
*
|
|
49
|
+
* 🔴 为什么是适配而不是新建一只:限速面在本仓已有属主(`observability/rate-limit.ts` + 两支 SQL 孪生),
|
|
50
|
+
* 而 SQL 孪生是**跨副本**的 —— enrollment 自带的 `createFixedWindowRateLimiter` 诚实地只是每副本窗
|
|
51
|
+
* (它自己的头注就这么写),对 token 撞库来说「N 个副本 = N 倍配额」。适配既有的那一只,撞库预算才是
|
|
52
|
+
* 全局的一份。
|
|
53
|
+
*
|
|
54
|
+
* 键前缀 `device-enroll:` 是必须的:RateGate 的键空间与 HTTP 面的 principal 键**共用一张表**,不加前缀
|
|
55
|
+
* 会让一次 enrollment 尝试吃掉调用方的普通请求配额(反过来也一样)。
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDeviceEnrollRateLimiter(gate: () => RateGate | undefined): DeviceEnrollRateLimiter;
|
|
58
|
+
/**
|
|
59
|
+
* 建齐 device lane 的三件。返回 `undefined` = **建不起来**(没有 SQL 后端 ⇒ 没有四表店)。
|
|
60
|
+
*
|
|
61
|
+
* 只在调用方判定本部署是 device 车道时调用;非 device 车道调用它是纯浪费(会白建一只 WS server)。
|
|
62
|
+
*/
|
|
63
|
+
export declare function createDeviceLane(ctx: DeviceLaneCtx): DeviceLane | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* **装配后**的拒启门(car A-1 的 `device-executor-unwired` 换来的真门;先例逐字 =
|
|
66
|
+
* `boot/leader.ts` 的 `assertLeaderDurableStore`)。
|
|
67
|
+
*
|
|
68
|
+
* config 层的门①判的是「运维配没配 SQL 坐标」——那句话回答的是**意图**。它回答不了「这次启动**真的
|
|
69
|
+
* 拿到**了店吗」:`openStoreBackendWithFallback` 在 `SESSION_BACKEND=auto`(默认)下,SQL 连不上 /
|
|
70
|
+
* 建表失败 ⇒ `backend=undefined` + 一条 warn 就继续启动。那次启动里 device lane 的四表整个不在,
|
|
71
|
+
* 而 `boot/execution-env.ts` 的第七臂拿不到三件 ⇒ `executionEnvFactory` 落 `undefined` ⇒ core 拿
|
|
72
|
+
* `StubExecutionEnv`:**一台自称在员工设备上执行的 worker 实际在自己进程里跑,而且回执同形**。
|
|
73
|
+
* 那正是 #157「执行车道禁静默降级」点名的样板病,也正是 A-1 那条临时门当初要拦的东西 —— 换执法点,
|
|
74
|
+
* 不是取消执法。
|
|
75
|
+
*
|
|
76
|
+
* 抛而不是 warn:降级的代价由用户承担(他以为命令跑在自己机器上、看到的却是云上一个空目录的结果)。
|
|
77
|
+
*/
|
|
78
|
+
export declare function assertDeviceLaneWired(isDeviceLane: boolean, lane: DeviceLane | undefined, hasFactory: boolean): void;
|
|
79
|
+
//# sourceMappingURL=device-lane.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createDeviceEnrollment, createFixedWindowRateLimiter } from "../device-enrollment.js";
|
|
2
|
+
import { createDeviceWsHub } from "../device-ws-hub.js";
|
|
3
|
+
export function createDeviceEnrollRateLimiter(gate) {
|
|
4
|
+
return {
|
|
5
|
+
check: async (key) => {
|
|
6
|
+
const g = gate();
|
|
7
|
+
if (!g)
|
|
8
|
+
throw new Error("device enrollment rate gate is not wired yet (boot window) — refusing rather than admitting unmetered enrollment attempts");
|
|
9
|
+
const verdict = g.check(`device-enroll:${key}`);
|
|
10
|
+
return { allowed: verdict.allowed, retryAfterSec: verdict.retryAfterSec };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function createDeviceLane(ctx) {
|
|
15
|
+
const { config, logger, metrics, backend, replicaId } = ctx;
|
|
16
|
+
const store = backend?.device?.();
|
|
17
|
+
if (!store)
|
|
18
|
+
return undefined;
|
|
19
|
+
const knobs = config.remoteExec?.provider === "device" ? config.remoteExec : undefined;
|
|
20
|
+
const enrollment = createDeviceEnrollment({
|
|
21
|
+
store,
|
|
22
|
+
...(ctx.rateGate ? { rateLimiter: createDeviceEnrollRateLimiter(ctx.rateGate) } : { rateLimiter: createFixedWindowRateLimiter({ limit: 10, windowMs: 60_000 }) }),
|
|
23
|
+
});
|
|
24
|
+
const hub = createDeviceWsHub({
|
|
25
|
+
store,
|
|
26
|
+
enrollment,
|
|
27
|
+
replicaId,
|
|
28
|
+
logger: {
|
|
29
|
+
warn: (msg, fields) => logger.warn(msg, fields),
|
|
30
|
+
error: (msg, fields) => logger.error(msg, fields),
|
|
31
|
+
info: (msg, fields) => logger.info(msg, fields),
|
|
32
|
+
},
|
|
33
|
+
metrics: { inc: (name, labels) => metrics.inc(name, labels) },
|
|
34
|
+
...(knobs?.deliveryTimeoutMs != null ? { dispatchTimeoutMs: knobs.deliveryTimeoutMs } : {}),
|
|
35
|
+
...(knobs?.reconnectGraceMs != null ? { reconnectGraceMs: knobs.reconnectGraceMs } : {}),
|
|
36
|
+
...(knobs?.maxInflightPerDevice != null ? { maxInflightPerDevice: knobs.maxInflightPerDevice } : {}),
|
|
37
|
+
...(knobs?.supersede ? { supersede: knobs.supersede } : {}),
|
|
38
|
+
});
|
|
39
|
+
logger.info("device_lane_enabled", {
|
|
40
|
+
replicaId,
|
|
41
|
+
maxInflightPerDevice: knobs?.maxInflightPerDevice ?? 4,
|
|
42
|
+
crossReplicaEnrollRateLimit: ctx.rateGate !== undefined,
|
|
43
|
+
});
|
|
44
|
+
return { store, enrollment, hub };
|
|
45
|
+
}
|
|
46
|
+
export function assertDeviceLaneWired(isDeviceLane, lane, hasFactory) {
|
|
47
|
+
if (!isDeviceLane)
|
|
48
|
+
return;
|
|
49
|
+
if (!lane) {
|
|
50
|
+
throw new Error("REMOTE_EXEC=device but the device lane could not be wired at boot [device-lane-unwired]: the SQL backend is " +
|
|
51
|
+
"configured yet this replica did not get one (an unreachable DB / failed DDL degrades to the in-memory fallback " +
|
|
52
|
+
"when SESSION_BACKEND=auto), so the device registry / session↔device binding / audit tables are absent. " +
|
|
53
|
+
"Refusing to serve a lane that would silently fall back to the in-process stub env — a worker claiming to " +
|
|
54
|
+
"execute on the employee's device while actually running inside its own process (#157). Fix the database " +
|
|
55
|
+
"(or unset REMOTE_EXEC) and restart (device-executor-lane-v2 §4.7)");
|
|
56
|
+
}
|
|
57
|
+
if (!hasFactory) {
|
|
58
|
+
throw new Error("REMOTE_EXEC=device but no DeviceExecutionEnv factory was built [device-lane-unwired]: the lane's stores are up " +
|
|
59
|
+
"yet `boot/execution-env.ts` produced no factory, so core would fall back to the in-process stub env. This is " +
|
|
60
|
+
"an assembly bug, not a configuration one — refusing to start rather than silently execute in-process (#157)");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=device-lane.js.map
|
|
@@ -5,19 +5,65 @@ import type { ServiceConfig } from "../config.js";
|
|
|
5
5
|
import type { Logger } from "../observability/logger.js";
|
|
6
6
|
import type { Metrics } from "../observability/metrics.js";
|
|
7
7
|
import { PerTaskImageRegistry } from "../per-task-image.js";
|
|
8
|
+
import type { DeviceEnrollment } from "../device-enrollment.js";
|
|
9
|
+
import type { DeviceStore } from "../device-store.js";
|
|
10
|
+
import type { DeviceWsHub } from "../device-ws-hub.js";
|
|
8
11
|
import { type TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
12
|
+
/**
|
|
13
|
+
* per-session 的 **cwd / shellEnv 登记簿**(TOC cwd seam + R-survey shellEnv seam)+ 它们的**租户围栏**。
|
|
14
|
+
*
|
|
15
|
+
* ── 为什么围栏不是可选的(codex R1-F1,判真)────────────────────────────────────────────────────
|
|
16
|
+
* 这两张表只按 `sessionId` 键控。在 host lane 上那是安全的:那条车道的闸带 `requirePrincipal !== true`,
|
|
17
|
+
* 整台机器就一个用户。**device lane 把这个前提撤掉了** —— 它的闸(`deviceCwdHonored`)刻意不要求单用户
|
|
18
|
+
* (员工各连各的设备)。于是一条真实的越权路径出现了:
|
|
19
|
+
* ① sessionId 由**调用方自选**,而且 `DELETE /v1/sessions/:id` 之后可以被**别人** `register` 重新登记
|
|
20
|
+
* (`boot/session-faces.ts` 的化身围栏注逐字写着这件事);
|
|
21
|
+
* ② 会话删除的级联删的是库里的行,**够不着这两张进程内的表**;
|
|
22
|
+
* ③ 新主如果没有重发 `settings.env`,工厂读到的就是**上一位主人的** shell env —— 那是秘密面(NPM_TOKEN
|
|
23
|
+
* 一类),而且会被注进新主自己设备上的指令;cwd 也会跨化身残留。
|
|
24
|
+
*
|
|
25
|
+
* 所以围栏做在**身份已知**的那一处:每次提交经 resolve-spec 调 {@link SessionScopedRegistries.fenceSessionOwner}
|
|
26
|
+
* 对拍,换主即清。删除侧再加一条 {@link SessionScopedRegistries.dropSession}(E21 级联)——那条不是为了
|
|
27
|
+
* 关这个洞(读面必经提交、提交必过围栏),而是**右侧删除权**:一次删除之后,秘密不该还留在进程内存里。
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ 诚实残余(如实登记,不假装关掉):两张表是**每副本进程内**的。围栏与清除都只在处理该请求/该删除的
|
|
30
|
+
* 那一个副本上发生。跨副本的陈值靠「读面必经本副本的一次提交 ⇒ 必过本副本的围栏」兜住 —— 这条推理成立
|
|
31
|
+
* 是因为 run 在受理它的那个副本上执行(ARCHITECTURE 副本模型)。device lane 的部署形本身也是单副本(§4.6)。
|
|
32
|
+
*/
|
|
33
|
+
export interface SessionScopedRegistries {
|
|
34
|
+
readonly perSessionCwd: Map<string, string>;
|
|
35
|
+
readonly perSessionShellEnv: Map<string, Record<string, string>>;
|
|
36
|
+
setSessionCwd(sid: string, cwd: string): void;
|
|
37
|
+
setSessionShellEnv(sid: string, env: Record<string, string>): void;
|
|
38
|
+
/** 记录/对拍本会话的主人。与上次不同 ⇒ **先清两张表**再记新主(换主即清)。`null` = 匿名会话,
|
|
39
|
+
* 它同样是一个**稳定**的身份值(不是「未知」),所以匿名重入不会被判成换主。 */
|
|
40
|
+
fenceSessionOwner(sid: string, owner: string | null): void;
|
|
41
|
+
/** 会话删除:两张表 + 身份记录一起清(留着身份记录会让同 id 的下一位与一个幽灵身份比对成「同主」)。 */
|
|
42
|
+
dropSession(sid: string): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function createSessionScopedRegistries(maxEntries?: number): SessionScopedRegistries;
|
|
45
|
+
/** device lane 的三件活对象(店 / 汇聚端 / 准入链)。缺席 = 本部署没有 device 车道。 */
|
|
46
|
+
export interface DeviceLaneWiring {
|
|
47
|
+
store: DeviceStore;
|
|
48
|
+
hub: DeviceWsHub;
|
|
49
|
+
enrollment: DeviceEnrollment;
|
|
50
|
+
}
|
|
9
51
|
export interface ExecutionEnvCtx {
|
|
10
52
|
config: ServiceConfig;
|
|
11
53
|
logger: Logger;
|
|
12
54
|
metrics: Metrics;
|
|
13
55
|
taskAttachmentStore: TaskAttachmentStore | undefined;
|
|
56
|
+
/** 车A-4:device lane 的装配件(`REMOTE_EXEC=device` 且 SQL 店在场时由 main.ts 建好传进来)。 */
|
|
57
|
+
deviceLane?: DeviceLaneWiring;
|
|
14
58
|
}
|
|
15
59
|
export declare function createExecutionEnv(ctx: ExecutionEnvCtx): {
|
|
16
60
|
perTaskImage: PerTaskImageRegistry;
|
|
17
61
|
sessionEnvSelection: SessionEnvironmentSelection;
|
|
18
62
|
perSessionCwd: Map<string, string>;
|
|
19
|
-
setSessionCwd: (sid: string,
|
|
20
|
-
setSessionShellEnv: (sid: string,
|
|
63
|
+
setSessionCwd: (sid: string, cwd: string) => void;
|
|
64
|
+
setSessionShellEnv: (sid: string, env: Record<string, string>) => void;
|
|
65
|
+
fenceSessionOwner: (sid: string, owner: string | null) => void;
|
|
66
|
+
dropSessionScoped: (sid: string) => void;
|
|
21
67
|
executionEnvFactory: import("@sema-agent/core").ExecutionEnvFactory | undefined;
|
|
22
68
|
worktreeReap: (() => Promise<void>) | undefined;
|
|
23
69
|
sendUserFileTaskEnvs: TaskEnvRegistry | undefined;
|
|
@@ -7,6 +7,7 @@ import { createE2bLspManager } from "../lsp/e2b-manager.js";
|
|
|
7
7
|
import { evictLspOnDestroy } from "../lsp-evict.js";
|
|
8
8
|
import { PerTaskImageRegistry } from "../per-task-image.js";
|
|
9
9
|
import { adbExecutionEnvFactory } from "../plugins/remote-env-adb.js";
|
|
10
|
+
import { deviceExecutionEnvFactory } from "../plugins/remote-env-device.js";
|
|
10
11
|
import { e2bExecutionEnvFactory } from "../plugins/remote-env-e2b.js";
|
|
11
12
|
import { hostExecutionEnvFactory, RemoteHostExecutionEnv } from "../plugins/remote-env-host.js";
|
|
12
13
|
import { k8sExecutionEnvFactory } from "../plugins/remote-env-k8s.js";
|
|
@@ -27,15 +28,51 @@ function boundedSessionSetter(map, maxEntries) {
|
|
|
27
28
|
map.delete(map.keys().next().value);
|
|
28
29
|
};
|
|
29
30
|
}
|
|
31
|
+
const MAX_CWD_SESSIONS = 4096;
|
|
32
|
+
export function createSessionScopedRegistries(maxEntries = MAX_CWD_SESSIONS) {
|
|
33
|
+
const perSessionCwd = new Map();
|
|
34
|
+
const perSessionShellEnv = new Map();
|
|
35
|
+
const owners = new Map();
|
|
36
|
+
const dropSession = (sid) => {
|
|
37
|
+
perSessionCwd.delete(sid);
|
|
38
|
+
perSessionShellEnv.delete(sid);
|
|
39
|
+
owners.delete(sid);
|
|
40
|
+
};
|
|
41
|
+
const touch = (sid, owner) => {
|
|
42
|
+
const next = owner === undefined ? (owners.has(sid) ? owners.get(sid) : null) : owner;
|
|
43
|
+
owners.delete(sid);
|
|
44
|
+
owners.set(sid, next);
|
|
45
|
+
while (owners.size > maxEntries)
|
|
46
|
+
dropSession(owners.keys().next().value);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
perSessionCwd,
|
|
50
|
+
perSessionShellEnv,
|
|
51
|
+
setSessionCwd: (sid, cwd) => {
|
|
52
|
+
perSessionCwd.set(sid, cwd);
|
|
53
|
+
touch(sid, undefined);
|
|
54
|
+
},
|
|
55
|
+
setSessionShellEnv: (sid, env) => {
|
|
56
|
+
perSessionShellEnv.set(sid, env);
|
|
57
|
+
touch(sid, undefined);
|
|
58
|
+
},
|
|
59
|
+
fenceSessionOwner: (sid, owner) => {
|
|
60
|
+
if (owners.has(sid) && owners.get(sid) !== owner) {
|
|
61
|
+
perSessionCwd.delete(sid);
|
|
62
|
+
perSessionShellEnv.delete(sid);
|
|
63
|
+
}
|
|
64
|
+
touch(sid, owner);
|
|
65
|
+
},
|
|
66
|
+
dropSession,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
30
69
|
export function createExecutionEnv(ctx) {
|
|
31
70
|
const { config, logger, metrics, taskAttachmentStore } = ctx;
|
|
32
71
|
const perTaskImage = new PerTaskImageRegistry();
|
|
33
72
|
const sessionEnvSelection = new SessionEnvironmentSelection();
|
|
34
|
-
const
|
|
35
|
-
const perSessionCwd =
|
|
36
|
-
const
|
|
37
|
-
const perSessionShellEnv = new Map();
|
|
38
|
-
const setSessionShellEnv = boundedSessionSetter(perSessionShellEnv, MAX_CWD_SESSIONS);
|
|
73
|
+
const sessionScoped = createSessionScopedRegistries();
|
|
74
|
+
const { perSessionCwd, setSessionCwd } = sessionScoped;
|
|
75
|
+
const { perSessionShellEnv, setSessionShellEnv } = sessionScoped;
|
|
39
76
|
let executionEnvFactory;
|
|
40
77
|
let worktreeReap;
|
|
41
78
|
if (config.remoteExec?.provider === "e2b") {
|
|
@@ -85,6 +122,19 @@ export function createExecutionEnv(ctx) {
|
|
|
85
122
|
...(config.remoteExec.hostKey !== undefined ? { hostKey: config.remoteExec.hostKey } : {}),
|
|
86
123
|
});
|
|
87
124
|
}
|
|
125
|
+
else if (config.remoteExec?.provider === "device") {
|
|
126
|
+
if (ctx.deviceLane) {
|
|
127
|
+
executionEnvFactory = deviceExecutionEnvFactory({
|
|
128
|
+
hub: ctx.deviceLane.hub,
|
|
129
|
+
store: ctx.deviceLane.store,
|
|
130
|
+
enrollment: ctx.deviceLane.enrollment,
|
|
131
|
+
...(config.remoteExec.execTimeoutMs != null ? { execTimeoutMs: config.remoteExec.execTimeoutMs } : {}),
|
|
132
|
+
perSessionCwd,
|
|
133
|
+
perSessionShellEnv,
|
|
134
|
+
logger,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
88
138
|
else if (config.remoteExec?.provider === "adb") {
|
|
89
139
|
executionEnvFactory = adbExecutionEnvFactory({
|
|
90
140
|
serial: config.remoteExec.serial,
|
|
@@ -236,6 +286,8 @@ export function createExecutionEnv(ctx) {
|
|
|
236
286
|
logger.info("lsp_enabled", { provider: lspProvider });
|
|
237
287
|
return {
|
|
238
288
|
perTaskImage, sessionEnvSelection, perSessionCwd, setSessionCwd, setSessionShellEnv,
|
|
289
|
+
fenceSessionOwner: sessionScoped.fenceSessionOwner,
|
|
290
|
+
dropSessionScoped: sessionScoped.dropSession,
|
|
239
291
|
executionEnvFactory, worktreeReap, sendUserFileTaskEnvs, lspManager,
|
|
240
292
|
};
|
|
241
293
|
}
|
|
@@ -40,6 +40,9 @@ export interface ResolveSpecCtx {
|
|
|
40
40
|
perSessionCwd: Map<string, string>;
|
|
41
41
|
setSessionCwd: (sid: string, cwd: string) => void;
|
|
42
42
|
setSessionShellEnv: (sid: string, env: Record<string, string>) => void;
|
|
43
|
+
/** 两张 per-session 登记簿的**租户围栏**(codex R1-F1):每次提交对拍本会话的主人,换主即清。
|
|
44
|
+
* 论证(为什么 device lane 把这条从「不需要」变成「必需」)逐字见 `createSessionScopedRegistries` 头注。 */
|
|
45
|
+
fenceSessionOwner: (sid: string, owner: string | null) => void;
|
|
43
46
|
hookLlm: ReturnType<typeof createHookLlm>["hookLlm"];
|
|
44
47
|
hookAgent: HookLlmCall;
|
|
45
48
|
fleetBus: FleetEventBus;
|
|
@@ -26,7 +26,7 @@ import { HttpError, encodeCheckpointScope } from "../security.js";
|
|
|
26
26
|
import { memorySpecForRequest } from "../memory-scope.js";
|
|
27
27
|
import { a2aForScenario, mcpForScenario } from "../config-center/facade.js";
|
|
28
28
|
import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
|
|
29
|
-
import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
29
|
+
import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
30
30
|
import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
|
|
31
31
|
import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
|
|
32
32
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
|
|
@@ -35,7 +35,7 @@ import { redactSecrets } from "../trace/redact.js";
|
|
|
35
35
|
import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
|
|
36
36
|
import { effectiveMemoryPersistenceCapable } from "./memory-boundary.js";
|
|
37
37
|
export function createResolveSpec(ctx) {
|
|
38
|
-
const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, handsLanes, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, lockedKeys, } = ctx;
|
|
38
|
+
const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, handsLanes, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, fenceSessionOwner, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, lockedKeys, } = ctx;
|
|
39
39
|
assertGuardPatternsUsable(config);
|
|
40
40
|
const onlySensitiveBaseline = buildOnlySensitiveBaselineWarning(config, { durableEnabled, singleUserAutoAcceptBaseline });
|
|
41
41
|
if (onlySensitiveBaseline)
|
|
@@ -116,17 +116,20 @@ export function createResolveSpec(ctx) {
|
|
|
116
116
|
};
|
|
117
117
|
const bindSettingsCwdEnvAndModel = (body, auth, gated, effMode, opts) => {
|
|
118
118
|
const { objective, parsedSettings } = gated;
|
|
119
|
+
const cwdWriteGate = cwdHonored(config) || deviceCwdHonored(config);
|
|
119
120
|
const execLane = config.remoteExec?.provider ?? "in-process";
|
|
121
|
+
if (auth?.sessionId)
|
|
122
|
+
fenceSessionOwner(auth.sessionId, auth.principal ?? null);
|
|
120
123
|
if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
|
|
121
|
-
if (
|
|
124
|
+
if (cwdWriteGate && isValidCwd(body.cwd))
|
|
122
125
|
setSessionCwd(auth.sessionId, body.cwd);
|
|
123
126
|
else if (inProcessSingleUserLane(config) && isValidCwd(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
|
|
124
127
|
logger.debug("task_cwd_inherited", { lane: execLane, sessionId: auth.sessionId });
|
|
125
128
|
else
|
|
126
|
-
logger.warn("task_cwd_ignored", { honored:
|
|
129
|
+
logger.warn("task_cwd_ignored", { honored: cwdWriteGate, lane: execLane, sessionId: auth.sessionId });
|
|
127
130
|
}
|
|
128
131
|
if (parsedSettings.settings?.shellEnv && auth?.sessionId) {
|
|
129
|
-
if (
|
|
132
|
+
if (cwdWriteGate)
|
|
130
133
|
setSessionShellEnv(auth.sessionId, parsedSettings.settings.shellEnv);
|
|
131
134
|
else if (inProcessSingleUserLane(config)) {
|
|
132
135
|
const requested = parsedSettings.settings.shellEnv;
|
|
@@ -204,22 +207,22 @@ export function createResolveSpec(ctx) {
|
|
|
204
207
|
let resumeAtEntryId;
|
|
205
208
|
if (typeof body.resumeAt === "string" && body.resumeAt.length > 0) {
|
|
206
209
|
if (!auth?.sessionId)
|
|
207
|
-
throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)");
|
|
210
|
+
throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)", { code: "resume_at.no_session" });
|
|
208
211
|
if (!resumeAnchorStore || !ownerAware.getLeafId)
|
|
209
212
|
throw new HttpError(501, "resume-at is not available on this worker (no session-store backend for the anchor map)");
|
|
210
213
|
resumeAtEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.resumeAt, auth.principal ?? null);
|
|
211
214
|
if (resumeAtEntryId === undefined)
|
|
212
|
-
throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)");
|
|
215
|
+
throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)", { code: "resume_at.unknown_event" });
|
|
213
216
|
}
|
|
214
217
|
let rewindFilesToEntryId;
|
|
215
218
|
if (resumeAtEntryId === undefined && typeof body.rewindFilesTo === "string" && body.rewindFilesTo.length > 0) {
|
|
216
219
|
if (!auth?.sessionId)
|
|
217
|
-
throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)");
|
|
220
|
+
throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)", { code: "rewind_files_to.no_session" });
|
|
218
221
|
if (!resumeAnchorStore || !ownerAware.getLeafId)
|
|
219
222
|
throw new HttpError(501, "rewind-files-to is not available on this worker (no session-store backend for the anchor map)");
|
|
220
223
|
rewindFilesToEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.rewindFilesTo, auth.principal ?? null);
|
|
221
224
|
if (rewindFilesToEntryId === undefined)
|
|
222
|
-
throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)");
|
|
225
|
+
throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)", { code: "rewind_files_to.unknown_event" });
|
|
223
226
|
}
|
|
224
227
|
const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId.toLowerCase() : undefined);
|
|
225
228
|
const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
|
|
@@ -6,6 +6,7 @@ import { PlanCacheProbe } from "../plan-cache-probe.js";
|
|
|
6
6
|
import type { CheckpointStoreFull, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
|
|
7
7
|
import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
8
8
|
import type { OwnerAwareSessionStore } from "../security.js";
|
|
9
|
+
import type { DeviceStore } from "../device-store.js";
|
|
9
10
|
import { SessionWatchRegistry } from "../session-watch.js";
|
|
10
11
|
import type { TaskListLane } from "./task-list-lane.js";
|
|
11
12
|
export interface SessionFacesCtx {
|
|
@@ -26,6 +27,10 @@ export interface SessionFacesCtx {
|
|
|
26
27
|
workflowCompletionInbox: WorkflowCompletionInbox | undefined;
|
|
27
28
|
/** #318:会话级任务清单车道(E21 级联的一条腿)。缺席 = 装配未接本车道 ⇒ 无清单可删。 */
|
|
28
29
|
taskListLane: TaskListLane | undefined;
|
|
30
|
+
/** device lane 的四表店(E21 级联的一条腿:`device_session` 绑定行随会话删)。缺席 = 非 SQL 后端。 */
|
|
31
|
+
deviceStore: DeviceStore | undefined;
|
|
32
|
+
/** per-session cwd/shellEnv 登记簿的清除口(E21 级联的一条腿,codex R1-F1)。缺席 = 装配未接。 */
|
|
33
|
+
dropSessionScoped: ((sid: string) => void) | undefined;
|
|
29
34
|
}
|
|
30
35
|
export declare function createSessionFaces(ctx: SessionFacesCtx): {
|
|
31
36
|
ownerAware: OwnerAwareSessionStore;
|
|
@@ -6,7 +6,7 @@ import { setLeafAdvanceListener } from "../session-leaf-bus.js";
|
|
|
6
6
|
import { SessionWatchRegistry, posIntEnv } from "../session-watch.js";
|
|
7
7
|
import { defaultTaskRegistry } from "@sema-agent/core";
|
|
8
8
|
export function createSessionFaces(ctx) {
|
|
9
|
-
const { config, logger, metrics, localRoot, backend, sessionStore, runStore, checkpointStore, toolResultStore, resumeAnchorStore, approvalExemptionStore, sessionPolicyStore, taskAttachmentStore, fileSnapshotStore, workflowCompletionInbox, taskListLane, } = ctx;
|
|
9
|
+
const { config, logger, metrics, localRoot, backend, sessionStore, runStore, checkpointStore, toolResultStore, resumeAnchorStore, approvalExemptionStore, sessionPolicyStore, taskAttachmentStore, fileSnapshotStore, workflowCompletionInbox, taskListLane, deviceStore, dropSessionScoped, } = ctx;
|
|
10
10
|
const mysqlPool = backend?.mysqlPool();
|
|
11
11
|
const auditPgPool = backend?.pgPool();
|
|
12
12
|
const auditLocalRoot = backend?.kind === "local" ? (config.localDataRoot ?? localRoot) : undefined;
|
|
@@ -63,6 +63,9 @@ export function createSessionFaces(ctx) {
|
|
|
63
63
|
await taskAttachmentStore.deleteBySession(sessionId);
|
|
64
64
|
if (taskListLane)
|
|
65
65
|
await taskListLane.deleteBySession(sessionId);
|
|
66
|
+
if (deviceStore)
|
|
67
|
+
await deviceStore.deleteByRootSession(sessionId);
|
|
68
|
+
dropSessionScoped?.(sessionId);
|
|
66
69
|
if (fileSnapshotStore?.deleteBySession)
|
|
67
70
|
await fileSnapshotStore.deleteBySession(sessionId);
|
|
68
71
|
if (workflowCompletionInbox)
|
package/dist/boot/shutdown.d.ts
CHANGED
|
@@ -72,6 +72,14 @@ export interface ShutdownCtx {
|
|
|
72
72
|
storeLiveProbe: {
|
|
73
73
|
stop(): void;
|
|
74
74
|
} | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* device lane 的 WS 汇聚端(§8-R7 发版排空,车A-4)。缺席 = 本部署没有 device 车道。
|
|
77
|
+
*
|
|
78
|
+
* 🔴 排空联动**必须**由装配层驱动(`http/server.ts` 的 `deviceHub` 键头注逐字):§8-R7 的顺序
|
|
79
|
+
* (新提交 503 → 新 upgrade 拒 → 停发新指令 → 在途结果照收 → 才断连)是**部署编排**的语义,
|
|
80
|
+
* HTTP 层不能自作主张。所以 `drainState.draining` 翻真的那一刻,这里同步调 `beginDrain()`。
|
|
81
|
+
*/
|
|
82
|
+
deviceHub: import("../device-ws-hub.js").DeviceWsHub | undefined;
|
|
75
83
|
/** #131-2:config 60s 刷新环(config-center startRefreshLoop)——不停的话停机中途还可能热应用
|
|
76
84
|
* 一份新配置。缺席 = 纯 env 部署没起环。 */
|
|
77
85
|
configCenter: {
|
package/dist/boot/shutdown.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createParentWatch } from "../parent-watch.js";
|
|
|
5
5
|
import { createEngineLeaseTracker } from "./engine-lease.js";
|
|
6
6
|
import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
|
|
7
7
|
export function installShutdownHandlers(ctx) {
|
|
8
|
-
const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, } = ctx;
|
|
8
|
+
const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, deviceHub, } = ctx;
|
|
9
9
|
const dataRoot = config.localDataRoot;
|
|
10
10
|
if (dataRoot) {
|
|
11
11
|
const prior = readCrashLast(dataRoot);
|
|
@@ -66,6 +66,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
66
66
|
parentWatch?.stop();
|
|
67
67
|
engineLease?.stop();
|
|
68
68
|
storeLiveProbe?.stop();
|
|
69
|
+
void deviceHub?.close().catch((err) => logger.warn("device_hub_close_failed", { err: String(err) }));
|
|
69
70
|
configCenter?.stopRefreshLoop();
|
|
70
71
|
otelExporter?.stop();
|
|
71
72
|
breakerState?.stop();
|
|
@@ -107,6 +108,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
107
108
|
}
|
|
108
109
|
draining = true;
|
|
109
110
|
drainState.draining = true;
|
|
111
|
+
deviceHub?.beginDrain();
|
|
110
112
|
drainState.since = Date.now();
|
|
111
113
|
void fleetClient?.announceNow();
|
|
112
114
|
const inflight = drainState.inflight?.() ?? 0;
|
package/dist/boot/stores.js
CHANGED
|
@@ -17,6 +17,7 @@ import { PgTaskAttachmentStore, TiDBTaskAttachmentStore, ensurePgTaskAttachmentS
|
|
|
17
17
|
import { createSessionStore } from "../plugins/session-store.js";
|
|
18
18
|
import { createTaskListLane } from "./task-list-lane.js";
|
|
19
19
|
import { ensurePgTaskListSchema, ensureTiDBTaskListSchema } from "../plugins/task-list-store-sql.js";
|
|
20
|
+
import { ensurePgDeviceSchema, ensureTiDBDeviceSchema } from "../plugins/device-store-sql.js";
|
|
20
21
|
import { assertCloudSnapshotBlobPosture, openStoreBackendWithFallback } from "../plugins/store-backend.js";
|
|
21
22
|
import { buildMemoryRemoteLaneWarn, memoryEngineBackendFor, memoryEngineRemoteLanePosture } from "../memory-scope.js";
|
|
22
23
|
import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
|
|
@@ -343,6 +344,14 @@ export async function openStores(ctx) {
|
|
|
343
344
|
await ensureTiDBTaskListSchema(mysqlPool);
|
|
344
345
|
return createTaskListLane({ mysqlPool, pgPool, logger });
|
|
345
346
|
})();
|
|
347
|
+
{
|
|
348
|
+
const mysqlPool = backend?.mysqlPool?.();
|
|
349
|
+
const pgPool = backend?.pgPool?.();
|
|
350
|
+
if (pgPool)
|
|
351
|
+
await ensurePgDeviceSchema(async (text, params) => pgPool.query(text, params));
|
|
352
|
+
if (mysqlPool)
|
|
353
|
+
await ensureTiDBDeviceSchema(mysqlPool);
|
|
354
|
+
}
|
|
346
355
|
const sessionStore = createSessionStore(config, backend, metrics);
|
|
347
356
|
if (config.requirePrincipal && backend?.kind === "local") {
|
|
348
357
|
throw new Error("REQUIRE_PRINCIPAL=true is not supported on the local file backend (DB_BACKEND=local): session/run CONTENT is " +
|
|
@@ -353,10 +353,17 @@ export interface EffectiveConfig {
|
|
|
353
353
|
/** (stage7 P5,registry-core 0.8 `execution` 域): the center-resolved per-principal execution
|
|
354
354
|
* ruling riding the caps view. `required=false` / ruling absent = no enforcement (legacy). `allowedLanes` is
|
|
355
355
|
* an OPEN value domain (adding one is never BREAKING; consumers tolerate unknown names). Known lane names:
|
|
356
|
-
* `host`/`e2b`/`k8s`/`ssh`/`adb`/`local-docker` (the REMOTE_EXEC providers) **plus `in-process`**
|
|
357
|
-
* REMOTE_EXEC-unset fleet posture (OA/review-style workers; named distinctly from the explicit
|
|
356
|
+
* `host`/`e2b`/`k8s`/`ssh`/`adb`/`local-docker`/`device` (the REMOTE_EXEC providers) **plus `in-process`**
|
|
357
|
+
* — the REMOTE_EXEC-unset fleet posture (OA/review-style workers; named distinctly from the explicit
|
|
358
358
|
* `host` lane on purpose). ⚠️ Admins writing `required=true` policies must include `in-process` when such
|
|
359
|
-
* workers should stay admittable — center UI/doc 词表待补齐。
|
|
359
|
+
* workers should stay admittable — center UI/doc 词表待补齐。
|
|
360
|
+
*
|
|
361
|
+
* 🔴 **`device` 是 per-principal 执行面上最需要写清的一条**(device-executor-lane-v2 §4.1 的 config-center
|
|
362
|
+
* 行,车A-4 补):它是「在**员工自己的机器**上执行」的那条车道 —— 允许它 ≠ 允许一台云沙箱,而是允许
|
|
363
|
+
* 这个 principal 把裁决过的指令投到一台**受管设备**上。⚠️ 但**别把它当设备准入门用**:本 ruling 的
|
|
364
|
+
* 粒度是 lane 名,它表达不了 principal×device 绑定,而且 no-ruling 是 **fail-open** 的
|
|
365
|
+
* (`runtime-caps-resolver.ts` 的 P 类债)。device lane 的真准入是自建的 fail-closed 查表
|
|
366
|
+
* (绑定行 owner 谓词 + 设备 active + presence,§4.3.2)—— center 白名单是**外加**的一层,不是替代。 */
|
|
360
367
|
export interface ExecutionRuling {
|
|
361
368
|
required: boolean;
|
|
362
369
|
allowedLanes: string[];
|
|
@@ -28,9 +28,9 @@ import type { ServiceConfigFlat } from "./config-types.js";
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const LIVENESS_HEARTBEAT_MS = 30000;
|
|
30
30
|
/** 闭集:新增一条不变量必须在这里加词(拼错 = 编译红,而不是运行期悄悄不判)。 */
|
|
31
|
-
export type CrossInvariantId = "run-stale-heartbeat" | "bind-posture" | "bake-runner-credential" | "direct-door-anchors" | "operator-principals-required" | "bake-operator-principals" | "sensitive-write-patterns" | "leader-host-lane" | "leader-durable-store";
|
|
31
|
+
export type CrossInvariantId = "run-stale-heartbeat" | "bind-posture" | "bake-runner-credential" | "direct-door-anchors" | "operator-principals-required" | "bake-operator-principals" | "sensitive-write-patterns" | "leader-host-lane" | "leader-durable-store" | "device-durable-store" | "device-durable-approval";
|
|
32
32
|
/** 表读的键集(取自平铺配置型 ⇒ 键名/类型永不与真配置漂)。 */
|
|
33
|
-
export type CrossInvariantView = Readonly<Pick<ServiceConfigFlat, "runStaleSec" | "authToken" | "authTokens" | "allowUnauthedWrites" | "imageBakes" | "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals" | "sensitiveWritePatterns" | "leaderEnabled" | "remoteExec" | "dbBackend">>;
|
|
33
|
+
export type CrossInvariantView = Readonly<Pick<ServiceConfigFlat, "runStaleSec" | "authToken" | "authTokens" | "allowUnauthedWrites" | "imageBakes" | "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals" | "sensitiveWritePatterns" | "leaderEnabled" | "remoteExec" | "dbBackend" | "tidb" | "pg">>;
|
|
34
34
|
export interface CrossInvariantInput {
|
|
35
35
|
/** 现役配置(boot 期 = 候选自身)。 */
|
|
36
36
|
readonly live: CrossInvariantView;
|
|
@@ -111,6 +111,24 @@ export const CONFIG_CROSS_INVARIANTS = [
|
|
|
111
111
|
? `LEADER_ENABLED with DB_BACKEND=${next.dbBackend} (no external SQL store) degrades the leader-run registry to a per-process Map`
|
|
112
112
|
: undefined,
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
id: "device-durable-store",
|
|
116
|
+
keys: ["remoteExec", "dbBackend", "tidb", "pg"],
|
|
117
|
+
restartReason: "device-lane",
|
|
118
|
+
bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:device-durable-store" },
|
|
119
|
+
check: ({ next }) => next.remoteExec?.provider === "device" && !((next.dbBackend === "mysql" && next.tidb !== undefined) || (next.dbBackend === "pg" && next.pg !== undefined))
|
|
120
|
+
? `REMOTE_EXEC=device with DB_BACKEND=${next.dbBackend} and no SQL connection coordinates leaves the device registry/binding/audit in a per-process map — a restart forgets which device a parked run must resume onto (DB_BACKEND alone is only an engine selector)`
|
|
121
|
+
: undefined,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "device-durable-approval",
|
|
125
|
+
keys: ["remoteExec", "durableApproval"],
|
|
126
|
+
restartReason: "device-lane",
|
|
127
|
+
bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:device-durable-approval" },
|
|
128
|
+
check: ({ next }) => next.remoteExec?.provider === "device" && !next.durableApproval
|
|
129
|
+
? "REMOTE_EXEC=device without DURABLE_APPROVAL=true has no park exit for an approval that outlives the device's offline window"
|
|
130
|
+
: undefined,
|
|
131
|
+
},
|
|
114
132
|
];
|
|
115
133
|
export function checkCrossInvariants(input) {
|
|
116
134
|
for (const inv of CONFIG_CROSS_INVARIANTS) {
|
package/dist/config-types.d.ts
CHANGED
|
@@ -577,6 +577,18 @@ export interface ServiceConfigFlat {
|
|
|
577
577
|
provider: "host";
|
|
578
578
|
workspaceBase?: string;
|
|
579
579
|
commandTimeoutMs?: number;
|
|
580
|
+
} | {
|
|
581
|
+
provider: "device";
|
|
582
|
+
/** `DEVICE_DELIVERY_TIMEOUT_MS`(默认 10s)= 等在途槽的上限;到期 = `device.busy`(§5.5)。 */
|
|
583
|
+
deliveryTimeoutMs?: number;
|
|
584
|
+
/** `DEVICE_RECONNECT_GRACE_MS`(默认 120s)= 断连后在途不判丢的窗(§5.4 同 epoch 重连)。 */
|
|
585
|
+
reconnectGraceMs?: number;
|
|
586
|
+
/** `DEVICE_EXEC_TIMEOUT_MS`(默认 1800s)= 指令级默认超时;core 的 per-command `timeout` 可收紧。 */
|
|
587
|
+
execTimeoutMs?: number;
|
|
588
|
+
/** `DEVICE_MAX_INFLIGHT_PER_DEVICE`(§11-O3 clay 已裁 = 4)。 */
|
|
589
|
+
maxInflightPerDevice?: number;
|
|
590
|
+
/** `DEVICE_SUPERSEDE`(默认 `stale_only`)= 同 deviceId 二连的顶替策略(§5.2)。 */
|
|
591
|
+
supersede?: "stale_only" | "deny";
|
|
580
592
|
} | {
|
|
581
593
|
provider: "local-docker";
|
|
582
594
|
image: string;
|