@sema-agent/server 7.49.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/USAGE.md +39 -0
- package/dist/boot/device-lane.d.ts +79 -0
- package/dist/boot/device-lane.js +63 -0
- package/dist/boot/engine-lease.d.ts +95 -0
- package/dist/boot/engine-lease.js +56 -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 +10 -0
- package/dist/boot/shutdown.js +19 -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 +32 -1
- package/dist/config.js +69 -10
- 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/fleet/subagent-tail-bus.d.ts +1 -1
- package/dist/fleet/subagent-tail-bus.js +10 -0
- package/dist/http/routes/approvals-assistant.js +1 -1
- package/dist/http/routes/capabilities.js +7 -2
- package/dist/http/routes/sessions.js +1 -1
- package/dist/http/routes/tasks.js +12 -6
- package/dist/http/send.d.ts +14 -0
- package/dist/http/send.js +5 -0
- package/dist/http/server.d.ts +19 -0
- package/dist/http/server.js +21 -2
- package/dist/http/wire-types.d.ts +1 -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/plugins/usage-window-store-sql.d.ts +2 -2
- package/dist/plugins/usage-window-store-sql.js +17 -6
- package/dist/task-cwd.d.ts +25 -0
- package/dist/task-cwd.js +3 -0
- package/dist/task-settings.js +3 -1
- package/dist/trace/core-keyset-guard.d.ts +3 -3
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +1 -0
- package/package.json +3 -3
package/dist/boot/shutdown.d.ts
CHANGED
|
@@ -64,12 +64,22 @@ export interface ShutdownCtx {
|
|
|
64
64
|
since?: number;
|
|
65
65
|
inflight?: () => number;
|
|
66
66
|
lastActivityAt?: () => number;
|
|
67
|
+
attachedStreams?: () => number;
|
|
68
|
+
lastAttachEndedAt?: () => number;
|
|
67
69
|
};
|
|
68
70
|
/** #131-2:store 活体探针(main 建)——不停的话 hardShutdown 后仍对正在关闭的池发探针,刷假
|
|
69
71
|
* store_probe_dead 告警(文件头契约 3 的同族漏网)。缺席 = 该部署形没建探针。 */
|
|
70
72
|
storeLiveProbe: {
|
|
71
73
|
stop(): void;
|
|
72
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;
|
|
73
83
|
/** #131-2:config 60s 刷新环(config-center startRefreshLoop)——不停的话停机中途还可能热应用
|
|
74
84
|
* 一份新配置。缺席 = 纯 env 部署没起环。 */
|
|
75
85
|
configCenter: {
|
package/dist/boot/shutdown.js
CHANGED
|
@@ -2,9 +2,10 @@ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
|
|
|
2
2
|
import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
|
|
3
3
|
import { createSighupIdleHandler } from "../sighup-idle.js";
|
|
4
4
|
import { createParentWatch } from "../parent-watch.js";
|
|
5
|
+
import { createEngineLeaseTracker } from "./engine-lease.js";
|
|
5
6
|
import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
|
|
6
7
|
export function installShutdownHandlers(ctx) {
|
|
7
|
-
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;
|
|
8
9
|
const dataRoot = config.localDataRoot;
|
|
9
10
|
if (dataRoot) {
|
|
10
11
|
const prior = readCrashLast(dataRoot);
|
|
@@ -45,6 +46,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
45
46
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
46
47
|
let closing = false;
|
|
47
48
|
let parentWatch;
|
|
49
|
+
let engineLease;
|
|
48
50
|
const hardShutdown = () => {
|
|
49
51
|
if (closing)
|
|
50
52
|
return;
|
|
@@ -62,7 +64,9 @@ export function installShutdownHandlers(ctx) {
|
|
|
62
64
|
.catch(() => undefined);
|
|
63
65
|
fleetReconcile.reconciler.stop();
|
|
64
66
|
parentWatch?.stop();
|
|
67
|
+
engineLease?.stop();
|
|
65
68
|
storeLiveProbe?.stop();
|
|
69
|
+
void deviceHub?.close().catch((err) => logger.warn("device_hub_close_failed", { err: String(err) }));
|
|
66
70
|
configCenter?.stopRefreshLoop();
|
|
67
71
|
otelExporter?.stop();
|
|
68
72
|
breakerState?.stop();
|
|
@@ -104,6 +108,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
104
108
|
}
|
|
105
109
|
draining = true;
|
|
106
110
|
drainState.draining = true;
|
|
111
|
+
deviceHub?.beginDrain();
|
|
107
112
|
drainState.since = Date.now();
|
|
108
113
|
void fleetClient?.announceNow();
|
|
109
114
|
const inflight = drainState.inflight?.() ?? 0;
|
|
@@ -145,6 +150,18 @@ export function installShutdownHandlers(ctx) {
|
|
|
145
150
|
log: (event, fields) => logger.info(event, fields),
|
|
146
151
|
});
|
|
147
152
|
}
|
|
153
|
+
if (config.engineAutoExit) {
|
|
154
|
+
engineLease = createEngineLeaseTracker({
|
|
155
|
+
attachedStreams: () => drainState.attachedStreams?.() ?? 0,
|
|
156
|
+
inflight: () => drainState.inflight?.() ?? 0,
|
|
157
|
+
lastActivityAt: () => drainState.lastActivityAt?.() ?? 0,
|
|
158
|
+
lastAttachEndedAt: () => drainState.lastAttachEndedAt?.() ?? 0,
|
|
159
|
+
isStopped: () => closing || draining,
|
|
160
|
+
drain: drainThenShutdown,
|
|
161
|
+
lingerMs: config.engineLingerMs,
|
|
162
|
+
log: (event, fields) => logger.info(event, fields),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
148
165
|
let disposed = false;
|
|
149
166
|
return {
|
|
150
167
|
dispose: () => {
|
|
@@ -162,6 +179,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
162
179
|
drainTick = undefined;
|
|
163
180
|
}
|
|
164
181
|
parentWatch?.stop();
|
|
182
|
+
engineLease?.stop();
|
|
165
183
|
},
|
|
166
184
|
};
|
|
167
185
|
}
|
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;
|
|
@@ -1223,6 +1235,25 @@ export interface ServiceConfigFlat {
|
|
|
1223
1235
|
* 已知残余(成文,不收窄):两拍窗口内 pid 被系统复用 ⇒ 探活看到的是**新**进程,监视判"父还活着"
|
|
1224
1236
|
* 而不自退。监视是**尽力自愈**、不是强一致的父子生命周期绑定。 Env: `SEMA_PARENT_PID`。 */
|
|
1225
1237
|
parentPid?: number;
|
|
1238
|
+
/** #118(黑板 [5371]②):引擎**附着租约自退**总开关。壳自 spawn 引擎的本机形里,壳退出之后引擎
|
|
1239
|
+
* 继续活着 = 占端口/占库连接/占模型配额的孤儿。设了本键 = 引擎每 15s 自查一次「还有没有人附着」,
|
|
1240
|
+
* **三合取**(无附着 SSE 流 ∧ 无在飞 run ∧ 无非探针 HTTP 触点)**持续**满 {@link engineLingerMs}
|
|
1241
|
+
* 才自退,且自退走的是与 SIGTERM **逐字同一条** graceful drain 腿(不是裸 `process.exit`)。
|
|
1242
|
+
* **默认 false** —— 服务器部署形永不自退(k8s replica 半夜没人用是正常低谷,不是「该退了」);
|
|
1243
|
+
* 壳 spawn 形由壳显式注入 `true`。**部署级旋钮无条件生效**,不派生自任何客户端表态。
|
|
1244
|
+
* 与 {@link parentPid} 是**两件**:那条问「生我的 pid 还在吗」(多壳共享一引擎时结构性问错),
|
|
1245
|
+
* 这条问「还有没有人附着」。两条可同时装,各走各的判据。 Env: `ENGINE_AUTO_EXIT`(默认 off)。 */
|
|
1246
|
+
engineAutoExit: boolean;
|
|
1247
|
+
/** #118:自退前三合取必须**持续**成立的静默窗。默认 60s —— 防的是**壳重启窗**:壳崩了/重启的 1-3s
|
|
1248
|
+
* 里附着数确实是 0,窗太短就在用户眼皮底下把引擎杀了,窗内任何一次重连即把计时清零。
|
|
1249
|
+
* {@link engineAutoExit} 关着时本值不生效,但**照样解析、坏值照样拒启**(部署级旋钮的解析不挂在
|
|
1250
|
+
* 另一个旋钮的表态上 —— 否则手滑值躺在配置里,等哪天有人打开自退时当场生效且从没人报过警)。
|
|
1251
|
+
* **坏值拒启**(#210 A 档):非安全整数 / 落在 `[1000, 86400000]`(1s…24h)之外一律 boot 期 throw
|
|
1252
|
+
* 带指路 —— 「把 60(秒)填进一个毫秒键」是这类旋钮最常见的手滑,而 60ms 的 linger 等于把整条防误杀
|
|
1253
|
+
* 腿静默摘掉;上界那一侧同样承重(codex R1-F2):`1e100` 能过朴素的「正整数吗」检查,而 elapsed
|
|
1254
|
+
* **永远追不上它** ⇒ 看着配好了、自退其实整件失效。要「永不自退」请用 `ENGINE_AUTO_EXIT=false`。
|
|
1255
|
+
* Env: `ENGINE_LINGER_MS`(默认 60000,闭区间 [1000, 86400000])。 */
|
|
1256
|
+
engineLingerMs: number;
|
|
1226
1257
|
/** E23 (shell-host contract): inbound MCP elicitation HITL. When enabled, the service mounts `RunnerDeps.onElicit` + the
|
|
1227
1258
|
* `POST /v1/elicitations/:id/respond` route so an opted-in MCP server (`McpServerSpec.elicitation`, itself default
|
|
1228
1259
|
* OFF) can ask the END USER for input mid-tool-call. LIVE-ONLY (no durable suspend; lost on crash/replica-change).
|
|
@@ -1496,7 +1527,7 @@ export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authToken
|
|
|
1496
1527
|
/** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
|
|
1497
1528
|
export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "hooksTimeoutMs" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "delegationEntryCaps" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
|
|
1498
1529
|
/** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
|
|
1499
|
-
export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
|
|
1530
|
+
export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "engineAutoExit" | "engineLingerMs" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
|
|
1500
1531
|
/** 组:observability(可观测)。 */
|
|
1501
1532
|
export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
|
|
1502
1533
|
/** 组:integrations(外部集成)。`mcpServers` / `a2aPeers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
|
package/dist/config.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
|
|
|
11
11
|
import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
|
|
12
12
|
import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
13
13
|
import { normalizeSshFingerprint } from "./ssh-host-key.js";
|
|
14
|
+
import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
|
|
14
15
|
export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
15
16
|
function csv(name) {
|
|
16
17
|
return (process.env[name] ?? "")
|
|
@@ -736,11 +737,17 @@ function parseDegradeOn(words) {
|
|
|
736
737
|
}
|
|
737
738
|
return words;
|
|
738
739
|
}
|
|
740
|
+
const PRESENTED_NAME_MAX = 160;
|
|
741
|
+
function presentToolName(name) {
|
|
742
|
+
const clean = name.replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
743
|
+
return clean.length <= PRESENTED_NAME_MAX ? clean : `${clean.slice(0, PRESENTED_NAME_MAX)}…(${name.length} chars)`;
|
|
744
|
+
}
|
|
739
745
|
function ccRuleFormGuidance(name) {
|
|
740
746
|
if (!name.includes("(") && !name.includes(")"))
|
|
741
747
|
return undefined;
|
|
742
|
-
|
|
743
|
-
|
|
748
|
+
const shown = presentToolName(name);
|
|
749
|
+
return (`CC-form rule specifier — this list matches the WHOLE tool name verbatim, so it can only ever match a tool literally named "${shown}". ` +
|
|
750
|
+
`Drop the parentheses to gate the tool itself ("${presentToolName(name.split("(")[0] ?? name)}"); ` +
|
|
744
751
|
`to decide per COMMAND use \`runtime.commandPolicy\` ({command, decision} — bare argv[0] names); ` +
|
|
745
752
|
`to put every shell call behind an approval use MANUAL_MODE_SHELL_GATE=always`);
|
|
746
753
|
}
|
|
@@ -783,7 +790,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
783
790
|
const ns = protocolOf(name);
|
|
784
791
|
if (ns === undefined) {
|
|
785
792
|
if (name.includes("__"))
|
|
786
|
-
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${name}`).join(" or ")}` });
|
|
793
|
+
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${presentToolName(name)}`).join(" or ")}` });
|
|
787
794
|
continue;
|
|
788
795
|
}
|
|
789
796
|
if (ns.parse(name) === undefined) {
|
|
@@ -794,7 +801,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
794
801
|
}
|
|
795
802
|
export function formatUnmatchableToolNames(source, bad) {
|
|
796
803
|
return (`${source} names tool(s) that can never match a live tool (these lists are RAW whole-name comparisons — core 5.0.0 RB-476 removed the alias/auto-prefix folds): ` +
|
|
797
|
-
bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
|
|
804
|
+
bad.map((b) => `${presentToolName(b.name)} → ${b.guidance}`).join("; ")).replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
798
805
|
}
|
|
799
806
|
function streamApprovalConfig() {
|
|
800
807
|
const cfg = {
|
|
@@ -962,6 +969,24 @@ function parentPidEnv() {
|
|
|
962
969
|
}
|
|
963
970
|
return { parentPid: n };
|
|
964
971
|
}
|
|
972
|
+
const ENGINE_LINGER_FLOOR_MS = 1_000;
|
|
973
|
+
const ENGINE_LINGER_CEIL_MS = 24 * 60 * 60 * 1_000;
|
|
974
|
+
function engineLingerMsEnv() {
|
|
975
|
+
const raw = process.env.ENGINE_LINGER_MS;
|
|
976
|
+
if (raw === undefined || raw.trim() === "")
|
|
977
|
+
return 60_000;
|
|
978
|
+
const n = parseNumOrFail("ENGINE_LINGER_MS", raw);
|
|
979
|
+
if (!Number.isSafeInteger(n) || n < ENGINE_LINGER_FLOOR_MS || n > ENGINE_LINGER_CEIL_MS) {
|
|
980
|
+
throw new Error(`env ENGINE_LINGER_MS="${raw}" must be an INTEGER number of MILLISECONDS in ` +
|
|
981
|
+
`[${ENGINE_LINGER_FLOOR_MS}, ${ENGINE_LINGER_CEIL_MS}] (1s … 24h) — it is how long ` +
|
|
982
|
+
`"nobody attached, no run in flight, no HTTP touch" must hold CONTINUOUSLY before the engine self-exits. ` +
|
|
983
|
+
`A value like "60" is the seconds-for-milliseconds slip: a 60ms window kills the engine inside a shell's own ` +
|
|
984
|
+
`restart gap. A value like "1e100" passes a naive "is it a positive integer" check but the elapsed clock can ` +
|
|
985
|
+
`NEVER reach it, which silently turns self-exit off while looking configured. ` +
|
|
986
|
+
`There is no "disable" value here — set ENGINE_AUTO_EXIT=false to turn self-exit off entirely.`);
|
|
987
|
+
}
|
|
988
|
+
return n;
|
|
989
|
+
}
|
|
965
990
|
function parseMemoryEmbedder(ctx) {
|
|
966
991
|
const endpoint = process.env.MEMORY_EMBEDDER_ENDPOINT || undefined;
|
|
967
992
|
const model = process.env.MEMORY_EMBEDDER_MODEL || undefined;
|
|
@@ -1259,10 +1284,11 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1259
1284
|
adb: ["ADB_SERIAL"],
|
|
1260
1285
|
host: [],
|
|
1261
1286
|
"local-docker": ["DOCKER_IMAGE"],
|
|
1287
|
+
device: [],
|
|
1262
1288
|
};
|
|
1263
|
-
const required = Object.
|
|
1289
|
+
const required = Object.entries(REQUIRED_ENVS_BY_LANE).find(([word]) => word === lane)?.[1];
|
|
1264
1290
|
if (required === undefined) {
|
|
1265
|
-
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid:
|
|
1291
|
+
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: ${Object.keys(REQUIRED_ENVS_BY_LANE).join(" | ")}. ` +
|
|
1266
1292
|
`An unrecognized value used to fall through to the host/in-process lane silently; refusing to start instead (#157).`);
|
|
1267
1293
|
}
|
|
1268
1294
|
const missing = required.filter((name) => !process.env[name]);
|
|
@@ -1367,9 +1393,19 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1367
1393
|
...(process.env.DOCKER_SANDBOX_ENV ? { env: resolveEnvForward(process.env.DOCKER_SANDBOX_ENV) } : {}),
|
|
1368
1394
|
}
|
|
1369
1395
|
:
|
|
1370
|
-
process.env.
|
|
1371
|
-
? {
|
|
1372
|
-
|
|
1396
|
+
process.env.REMOTE_EXEC === "device"
|
|
1397
|
+
? {
|
|
1398
|
+
provider: "device",
|
|
1399
|
+
...((n) => (n !== undefined ? { deliveryTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_DELIVERY_TIMEOUT_MS")),
|
|
1400
|
+
...((n) => (n !== undefined ? { reconnectGraceMs: n } : {}))(optNumEnvStrict("DEVICE_RECONNECT_GRACE_MS")),
|
|
1401
|
+
...((n) => (n !== undefined ? { execTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_EXEC_TIMEOUT_MS")),
|
|
1402
|
+
...((n) => (n !== undefined ? { maxInflightPerDevice: n } : {}))(optNumEnvStrict("DEVICE_MAX_INFLIGHT_PER_DEVICE")),
|
|
1403
|
+
...(process.env.DEVICE_SUPERSEDE ? { supersede: enumEnv("DEVICE_SUPERSEDE", "stale_only", ["stale_only", "deny"]) } : {}),
|
|
1404
|
+
}
|
|
1405
|
+
:
|
|
1406
|
+
process.env.CONFIG_PROVIDER === "local"
|
|
1407
|
+
? { provider: "host" }
|
|
1408
|
+
: undefined,
|
|
1373
1409
|
worktreeIsolation: boolEnv("WORKTREE_ISOLATION_ENABLED", false) && process.env.WORKTREE_REPO_ROOT
|
|
1374
1410
|
? {
|
|
1375
1411
|
repoRoot: process.env.WORKTREE_REPO_ROOT,
|
|
@@ -1472,6 +1508,8 @@ function parseLimitsHttpDomain() {
|
|
|
1472
1508
|
drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), LIMITS_HTTP_FLOORS.drainGraceMs),
|
|
1473
1509
|
sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000),
|
|
1474
1510
|
...parentPidEnv(),
|
|
1511
|
+
engineAutoExit: boolEnv("ENGINE_AUTO_EXIT", false),
|
|
1512
|
+
engineLingerMs: engineLingerMsEnv(),
|
|
1475
1513
|
rateLimitPerMin: numEnv("RATE_LIMIT_RPM", "0"),
|
|
1476
1514
|
maxTaskCostUsd: numEnv("MAX_TASK_COST_USD", "0"),
|
|
1477
1515
|
maxTaskTokens: numEnv("MAX_TASK_TOKENS", "0"),
|
|
@@ -1667,7 +1705,9 @@ const ORCHESTRATION_GROUP_KEYS = [
|
|
|
1667
1705
|
];
|
|
1668
1706
|
const LIMITS_HTTP_GROUP_KEYS = [
|
|
1669
1707
|
"port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
|
|
1670
|
-
"attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid",
|
|
1708
|
+
"attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid",
|
|
1709
|
+
"engineAutoExit", "engineLingerMs",
|
|
1710
|
+
"rateLimitPerMin", "maxTaskCostUsd",
|
|
1671
1711
|
"maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
|
|
1672
1712
|
];
|
|
1673
1713
|
const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
|
|
@@ -1781,6 +1821,25 @@ export function loadConfig() {
|
|
|
1781
1821
|
"push. Point the service at a durable store (DB_BACKEND=mysql|pg), or turn the leader off (unset LEADER_ENABLED) " +
|
|
1782
1822
|
"(#193 车4 件2 / staleness ledger P1-9+P1-10 boot invariant)");
|
|
1783
1823
|
}
|
|
1824
|
+
{
|
|
1825
|
+
const deviceLane = orchestration.remoteExec?.provider === "device";
|
|
1826
|
+
const deviceSqlStore = (store.dbBackend === "mysql" && store.tidb !== undefined) || (store.dbBackend === "pg" && store.pg !== undefined);
|
|
1827
|
+
if (deviceLane && !deviceSqlStore) {
|
|
1828
|
+
throw new Error(`REMOTE_EXEC=device with DB_BACKEND=${store.dbBackend} and no SQL connection coordinates built is refused ` +
|
|
1829
|
+
"[device-durable-store]: the device registry, the session↔device binding and the device audit trail are all " +
|
|
1830
|
+
"SQL-backed — without a real SQL store the binding lives in this process only, so a restart forgets WHICH device " +
|
|
1831
|
+
"a parked run must resume onto and a second replica cannot see it at all. Note DB_BACKEND alone is not enough: " +
|
|
1832
|
+
"SESSION_BACKEND=memory (or auto with no MYSQL_HOST/PG_HOST) builds no coordinates at all. Configure a durable " +
|
|
1833
|
+
"store (DB_BACKEND=mysql|pg + its *_HOST/*_USER/*_DATABASE), or unset REMOTE_EXEC " +
|
|
1834
|
+
"(device-executor-lane-v2 §4.7 boot invariant)");
|
|
1835
|
+
}
|
|
1836
|
+
if (deviceLane && !dgDurable) {
|
|
1837
|
+
throw new Error("REMOTE_EXEC=device without DURABLE_APPROVAL=true is refused [device-durable-approval]: a personal device goes " +
|
|
1838
|
+
"offline (lid closed, network drop) at arbitrary points, so an in-flight approval that outlives the window MUST " +
|
|
1839
|
+
"have a park exit — without the durable approval store there is none and the run is stuck with nowhere to persist. " +
|
|
1840
|
+
"Set DURABLE_APPROVAL=true, or unset REMOTE_EXEC (device-executor-lane-v2 §4.7 boot invariant)");
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1784
1843
|
EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
|
|
1785
1844
|
return attachConfigGroups({
|
|
1786
1845
|
...store,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device lane 准入链(design/device-executor-lane-v2 §4.3.2 提交期准入 + §7-T5 enrollment 五件 +
|
|
3
|
+
* §7-T6 反枚举)—— **纯函数层**:没有 HTTP、没有 WS、没有 core 装配,只有「店 + 判据 → typed 判决」。
|
|
4
|
+
* 端点(`/v1/devices/*`、`GET /v1/device/ws`)由车A-3/车C 接;它们**必须**经由这里的判决,不得自建判据。
|
|
5
|
+
*
|
|
6
|
+
* ── 五件(§7-T5,逐条落在哪儿)────────────────────────────────────────────────────────────────────
|
|
7
|
+
* ① TTL —— `DeviceStore.consumeEnrollToken` 的 CAS 谓词 `expires_at > NOW(3)`(**DB 时钟**,§4.6-F18)。
|
|
8
|
+
* ② 绑定 principal —— 同一条 CAS 谓词里 `target_tenant/target_subject` 逐字节等值;不匹配 = 拒。
|
|
9
|
+
* ③ 消费一次性 CAS —— `consumed_at IS NULL` 谓词 + `rev=rev+1`;**affected=0 时只有同 pubkey 放行**。
|
|
10
|
+
* ④ 重用审计 —— 异 pubkey ⇒ `device_enroll_token_reuse` 审计行(告警级);principal 不符 ⇒
|
|
11
|
+
* `device_identity_mismatch` 审计行。两条都在**拒绝之前**写,失败即整条请求失败(fail-closed:
|
|
12
|
+
* 审计写不进去 ≠ 放行)。
|
|
13
|
+
* ⑤ 限速 —— {@link DeviceEnrollRateLimiter},注入式。缺席 = **不限速**,所以端点车必须传一个
|
|
14
|
+
* (§7-T5 ⑤ 双限速);本层不替它兜底,也不假装限了。
|
|
15
|
+
*
|
|
16
|
+
* ── 反枚举(§4.8 + memory `anti-enum-404-ambiguity`)──────────────────────────────────────────────
|
|
17
|
+
* enrollment 的**全部**拒绝理由(token 不存在 / 过期 / principal 不符 / 被别的 pubkey 用过)在 wire
|
|
18
|
+
* 上是**同一个** `device.enrollment_invalid`;设备读面的「不存在」与「不是你的」是同一个
|
|
19
|
+
* `not_found.device`。细分只进审计。
|
|
20
|
+
*
|
|
21
|
+
* ── 吊销在途语义(§8-R4)──────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* `revoked` 是终态:①绑定的 session 后续提交 = `device.revoked`(admitSession 步 2,**先于** presence
|
|
23
|
+
* 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
|
|
24
|
+
* 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
|
|
25
|
+
*/
|
|
26
|
+
import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
|
|
27
|
+
/** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
|
|
28
|
+
export interface DeviceRejection {
|
|
29
|
+
code: DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
|
|
30
|
+
status: number;
|
|
31
|
+
message: string;
|
|
32
|
+
/** 限流专用:`Retry-After` 的秒值(附录 A 的 429 族纪律)。 */
|
|
33
|
+
retryAfterSec?: number;
|
|
34
|
+
}
|
|
35
|
+
export type DeviceOutcome<T> = {
|
|
36
|
+
ok: true;
|
|
37
|
+
value: T;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reject: DeviceRejection;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 限速座(§7-T5 ⑤)。**fail-closed**:`check` 抛错 = 拒绝(不是放行)—— 限速器不可用时放行,正是
|
|
44
|
+
* token 撞库最想要的那一刻。
|
|
45
|
+
*/
|
|
46
|
+
export interface DeviceEnrollRateLimiter {
|
|
47
|
+
check(key: string): Promise<{
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
retryAfterSec?: number;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export interface EnrollDeviceInput {
|
|
53
|
+
/** 明文 token(**只**在这里出现,落库前立刻摘要)。 */
|
|
54
|
+
token: string;
|
|
55
|
+
/** 请求方**验证过**的复合身份(SSO/凭据链产出,恒不取自可伪造头)。 */
|
|
56
|
+
requester: DeviceOwner;
|
|
57
|
+
/** 🔴 `deviceId` 与 `owner` **不在**这里:前者恒由 server 铸(调用方选 id = 让请求方挑自己的设备身份,
|
|
58
|
+
* 与 §6「server 铸 dev_<ulid>」相悖),后者恒取自**验证过的** requester —— 两个字段都不接受请求体供给。
|
|
59
|
+
* 测试要确定性 id ⇒ 注入 {@link DeviceEnrollmentDeps.mintId},不是从入参开一个口子。 */
|
|
60
|
+
device: Omit<NewDeviceInput, "deviceId" | "owner">;
|
|
61
|
+
}
|
|
62
|
+
export interface EnrollDeviceValue {
|
|
63
|
+
device: DeviceRow;
|
|
64
|
+
/** `true` = 幂等重放(响应丢失重试),`false` = 本次真注册。wire 上两者同为 200 + 同 deviceId。 */
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface AdmitSessionInput {
|
|
68
|
+
rootSessionId: string;
|
|
69
|
+
requester: DeviceOwner;
|
|
70
|
+
/** 提交体携带的 deviceId(§4.3.2:仅根任务携;缺席 = 沿用绑定)。 */
|
|
71
|
+
requestedDeviceId?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface AdmitSessionValue {
|
|
74
|
+
deviceId: string;
|
|
75
|
+
device: DeviceRow;
|
|
76
|
+
/**
|
|
77
|
+
* 判决**当刻**的连接世代(§4.6 fencing token)。
|
|
78
|
+
*
|
|
79
|
+
* 🔴 **准入判决是一个快照,不是一把锁**(codex 对抗轮 F4,判真但归属下一车):`admitSession` 返回后、
|
|
80
|
+
* 调用方真正下发指令前,一次 `revokeDevice` 可以提交 —— 它会作废租约并把 `conn_generation` 推进一格,
|
|
81
|
+
* 但它**追不回**已经基于旧判决发出去的指令。这不是本层能关掉的窗(§6 注③ 刻意不落指令表,所以持久层
|
|
82
|
+
* 没有「派发」这个写点可以带谓词);设计给的答案是**在下发边界重新断言世代**(§4.6 步 2/3:每一次带
|
|
83
|
+
* `conn_generation = ?` 谓词的写影响 0 行 = 本连接已失权 ⇒ 停发并断连)。
|
|
84
|
+
* 本字段就是那个断言要用的值 —— 车A-3 的 WS 汇聚端**必须**拿它去 fence,拿不到就不要下发。
|
|
85
|
+
* 残余诚实:窗口上界 = 心跳周期(吊销后设备侧最迟一个心跳失败即失权),§8-R4 对已发指令的账面处置是
|
|
86
|
+
* 「在途结果帧丢弃 + 审计,对应调用按 outcome-unknown 收尾」。
|
|
87
|
+
*/
|
|
88
|
+
connGeneration: number;
|
|
89
|
+
}
|
|
90
|
+
export interface DeviceEnrollment {
|
|
91
|
+
enroll(input: EnrollDeviceInput): Promise<DeviceOutcome<EnrollDeviceValue>>;
|
|
92
|
+
bindPlacement(input: {
|
|
93
|
+
rootSessionId: string;
|
|
94
|
+
deviceId: string;
|
|
95
|
+
requester: DeviceOwner;
|
|
96
|
+
}): Promise<DeviceOutcome<DeviceSessionRow>>;
|
|
97
|
+
admitSession(input: AdmitSessionInput): Promise<DeviceOutcome<AdmitSessionValue>>;
|
|
98
|
+
revoke(input: {
|
|
99
|
+
deviceId: string;
|
|
100
|
+
requester: DeviceOwner;
|
|
101
|
+
revokedBy: string;
|
|
102
|
+
reason?: string | null;
|
|
103
|
+
}): Promise<DeviceOutcome<DeviceRow>>;
|
|
104
|
+
}
|
|
105
|
+
export interface DeviceEnrollmentDeps {
|
|
106
|
+
store: DeviceStore;
|
|
107
|
+
/** 缺席 = 不限速(端点车必须传;见文件头注 ⑤)。 */
|
|
108
|
+
rateLimiter?: DeviceEnrollRateLimiter;
|
|
109
|
+
/** 测试可注入确定性铸造;默认 `mintDeviceId()`。 */
|
|
110
|
+
mintId?: () => string;
|
|
111
|
+
}
|
|
112
|
+
export declare function createDeviceEnrollment(deps: DeviceEnrollmentDeps): DeviceEnrollment;
|
|
113
|
+
/**
|
|
114
|
+
* 进程内固定窗限速(§7-T5 ⑤ 的**默认**实现)。
|
|
115
|
+
*
|
|
116
|
+
* 🔴 诚实边界:这是**每副本**的窗。跨副本的真限速属主是既有的 `observability/rate-limit.ts` RateGate
|
|
117
|
+
* (SQL 后端),端点车把它适配成 {@link DeviceEnrollRateLimiter} 传进来即可 —— 本函数不假装自己是
|
|
118
|
+
* 那个东西,也不在多副本部署下声称有全局上界。
|
|
119
|
+
*/
|
|
120
|
+
export declare function createFixedWindowRateLimiter(opts: {
|
|
121
|
+
limit: number;
|
|
122
|
+
windowMs: number;
|
|
123
|
+
now?: () => number;
|
|
124
|
+
}): DeviceEnrollRateLimiter;
|
|
125
|
+
//# sourceMappingURL=device-enrollment.d.ts.map
|