@sema-agent/server 7.54.0 → 7.55.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.
Files changed (36) hide show
  1. package/dist/approval-ask-audit-store.d.ts +129 -0
  2. package/dist/approval-ask-audit-store.js +284 -0
  3. package/dist/approval-card.d.ts +18 -6
  4. package/dist/approval-card.js +2 -2
  5. package/dist/approval-reconciler.d.ts +2 -1
  6. package/dist/approval-reconciler.js +1 -1
  7. package/dist/boot/coordinators.d.ts +2 -0
  8. package/dist/boot/coordinators.js +18 -1
  9. package/dist/boot/reapers.d.ts +2 -1
  10. package/dist/boot/stores.js +8 -1
  11. package/dist/device-store.d.ts +66 -2
  12. package/dist/device-store.js +35 -0
  13. package/dist/device-ws-hub.d.ts +8 -0
  14. package/dist/device-ws-hub.js +6 -0
  15. package/dist/http/routes/approvals-assistant.js +1 -0
  16. package/dist/http/routes/devices.d.ts +64 -0
  17. package/dist/http/routes/devices.js +173 -0
  18. package/dist/http/server.d.ts +14 -0
  19. package/dist/http/server.js +13 -2
  20. package/dist/main.js +3 -1
  21. package/dist/observability/fail-open.d.ts +7 -3
  22. package/dist/observability/fail-open.js +7 -3
  23. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  24. package/dist/plugins/approval-ask-store-memory.js +20 -3
  25. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  26. package/dist/plugins/approval-ask-store-sql.js +41 -10
  27. package/dist/plugins/device-store-sql.d.ts +38 -1
  28. package/dist/plugins/device-store-sql.js +82 -2
  29. package/dist/plugins/remote-env-device.js +8 -1
  30. package/dist/plugins/sql-errors.d.ts +12 -0
  31. package/dist/plugins/sql-errors.js +10 -0
  32. package/dist/runs.js +1 -0
  33. package/dist/tool-approval.d.ts +54 -23
  34. package/dist/tool-approval.js +218 -127
  35. package/dist/trace/ledger-events.d.ts +9 -0
  36. package/package.json +2 -2
@@ -28,6 +28,13 @@ export declare const DEVICE_ERROR_CODES: {
28
28
  readonly ENROLLMENT_INVALID: "device.enrollment_invalid";
29
29
  /** 反枚举 404:设备不存在 **与** 设备不是你的,逐字节同形。 */
30
30
  readonly NOT_FOUND: "not_found.device";
31
+ /** O4 换绑动词(2026-08-30 v2 稿 §2 步 3):前态 CAS 输 —— 体带当前 rev(`currentRev`,[ref]
32
+ * currentPending 哲学,壳一跳重定位)。**唯一铸造点 = `POST /v1/devices/sessions/:rootSessionId/rebind`**
33
+ * (管理面,`http/routes/devices.ts`);adapter/准入链结构上不产它。 */
34
+ readonly REBIND_REV_CONFLICT: "device.rebind_rev_conflict";
35
+ /** O4 换绑动词(v2 稿 §2 步 4):该根会话名下有非终态 run(durable 判据 = run store 的 session
36
+ * claim;hub 内存在途计数**不作门**——跨副本不可见)—— 体带 `activeTaskId`。铸造点同上一条。 */
37
+ readonly REBIND_ACTIVE_RUN: "device.rebind_active_run";
31
38
  };
32
39
  /** {@link DEVICE_ERROR_CODES} 的值联合 —— 闭集(未知码 = 编译错误,[ref] 词表纪律)。 */
33
40
  export type DeviceErrorCode = (typeof DEVICE_ERROR_CODES)[keyof typeof DEVICE_ERROR_CODES];
@@ -60,9 +67,13 @@ export declare const DEVICE_ERROR_STATUS: Readonly<Record<DeviceRejectCode, numb
60
67
  *
61
68
  * ⚠️ §4.8 的**遥测**清单比这张表长(`device_connected`/`device_disconnected`/`device_heartbeat_lost`/
62
69
  * `device_instruction_dispatched`/`device_outcome_unknown`)—— 那些是**计数器/日志**面,不落审计表。
63
- * 只有下面这 8 个是「安全轴事件,必须可追溯查询」。加成员 = 同时改这里 + 两侧 DDL 的 CHECK
70
+ * 只有下面这 9 个是「安全轴事件,必须可追溯查询」。加成员 = 同时改这里 + 两侧 DDL 的 CHECK
71
+ * (`AUDIT_EVENT_CHECK` 由本数组生成,DDL 自动跟);**且**存量表的旧 CHECK 不会被
72
+ * `CREATE TABLE IF NOT EXISTS` 更新 —— 升级探针见 `device-store-sql.ts` 的
73
+ * `assertDeviceAuditRebindEventSchema`(能力探测:PG 强制 CHECK、TiDB 默认不执行,探的是**行为**
74
+ * 不是元数据 —— memory `capability-detection-not-impl-names` 同判)。
64
75
  */
65
- export declare const DEVICE_AUDIT_EVENTS: readonly ["device_enrolled", "device_revoked", "device_token_rotated", "device_enroll_token_reuse", "device_frame_unexpected", "device_identity_mismatch", "device_superseded", "device_attached_elsewhere"];
76
+ export declare const DEVICE_AUDIT_EVENTS: readonly ["device_enrolled", "device_revoked", "device_token_rotated", "device_enroll_token_reuse", "device_frame_unexpected", "device_identity_mismatch", "device_superseded", "device_attached_elsewhere", "session_rebound"];
66
77
  export type DeviceAuditEvent = (typeof DEVICE_AUDIT_EVENTS)[number];
67
78
  /** v1 平台闭集(AR-R1-8)。win32 = follow-on(§10 车A-4 的 flavor 注)。 */
68
79
  export declare const DEVICE_PLATFORM_OS: readonly ["darwin", "linux"];
@@ -158,6 +169,10 @@ export declare function assertDevicePubkeyShape(pubkey: string): void;
158
169
  export declare function assertNewDeviceShape(input: NewDeviceInput): void;
159
170
  export declare function assertIssueTokenShape(input: IssueEnrollTokenInput): void;
160
171
  export declare function assertRevokeShape(input: RevokeDeviceInput): void;
172
+ /** O4 换绑入参的整形门(三形共用)。`expectedRev` 收窄为非负整数:它要进 SQL 等值谓词,小数/负数/NaN
173
+ * 在两方言的 BIGINT 比对下不是「不匹配」而是各自的隐式转换歧义面 —— 在门口拒,方言分歧面消失。
174
+ * ⚠️ HTTP 层对坏形先答 400(业务拒绝);这里的抛是**编程/装配错误**的兜底,不是 wire 分支。 */
175
+ export declare function assertRebindShape(input: RebindSessionInput): void;
161
176
  export declare function assertAcquireShape(input: AcquireConnectionInput): void;
162
177
  /** token 摘要必须是**规范形**(小写 sha256 hex):大小写混形在 VARBINARY/COLLATE "C" 下是两个键,
163
178
  * 于是同一个明文 token 能被消费两次 —— 这是 §7-T5 ③「一次性 CAS」的直接跳过面。 */
@@ -273,6 +288,47 @@ export type BindSessionResult = {
273
288
  } | {
274
289
  outcome: "device_revoked";
275
290
  };
291
+ /**
292
+ * O4 显式换绑(2026-08-30 v2 稿 §2)。`owner` = **绑定行的**复合身份(HTTP 层已裁决:属主同域或
293
+ * explicit operator;operator 代操作时传的也是行的 owner,不是操作员自己)——店内所有谓词恒带它,
294
+ * 判别位在店里就消失(反枚举,文件头注)。`expectedRev` = 壳从 S-4 读面拿到的前态(强制显式,
295
+ * 防两操作员并发换绑互踩)。
296
+ */
297
+ export interface RebindSessionInput {
298
+ rootSessionId: string;
299
+ toDeviceId: string;
300
+ expectedRev: number;
301
+ owner: DeviceOwner;
302
+ }
303
+ /**
304
+ * O4 换绑的判别式结果(门序 = v2 稿 §2:目标设备在场 → 幂等短路 → 前态 CAS → 审计;活跃 run 门在
305
+ * **HTTP 层**、CAS 之前判 —— run store 是另一只店,CAS 语句不兼职)。
306
+ */
307
+ export type RebindSessionResult =
308
+ /** CAS 赢家:`rev+1` + `bound_at` 刷新 + `session_rebound` 审计行**同一事务**提交。 */
309
+ {
310
+ outcome: "rebound";
311
+ row: DeviceSessionRow;
312
+ }
313
+ /** 真幂等:`toDeviceId` ≡ 当前绑定 ∧ `expectedRev` ≡ 当前 rev ⇒ 回显不加 rev、不写审计。 */
314
+ | {
315
+ outcome: "idempotent";
316
+ row: DeviceSessionRow;
317
+ }
318
+ /** 前态输(rev 不符,含「toDeviceId 同但 rev 错」——前态错就是错)⇒ 携当前行供壳一跳重定位。 */
319
+ | {
320
+ outcome: "rev_conflict";
321
+ row: DeviceSessionRow;
322
+ }
323
+ /** 绑定行不在**或**不是该 owner 的 —— 反枚举同形(HTTP 层折 404 同串)。 */
324
+ | {
325
+ outcome: "binding_not_found";
326
+ }
327
+ /** 目标设备不在 / 不是该 owner 的 / **已吊销** —— v2 稿 §2 步 2:三臂同折 404(设备 id 也是
328
+ * 不可枚举面);吊销不单列 = 判别位在店里就消失,消费层想泄也泄不出来。 */
329
+ | {
330
+ outcome: "device_not_found";
331
+ };
276
332
  export interface RevokeDeviceInput {
277
333
  deviceId: string;
278
334
  owner: DeviceOwner;
@@ -344,12 +400,18 @@ export interface DeviceStore {
344
400
  /** 管理面/测试用的无 owner 谓词读口 —— **不得**接到任何 principal-scoped 的 wire 面上。 */
345
401
  getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
346
402
  listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
403
+ /** O4/S-4:全量列表(operator 面专用;`getDeviceUnscoped` 同一条纪律 —— 不得接到任何
404
+ * principal-scoped 的 wire 面上,消费点必须先过 explicit operator 门)。 */
405
+ listDevicesUnscoped(): Promise<DeviceRow[]>;
347
406
  revokeDevice(input: RevokeDeviceInput): Promise<RevokeDeviceResult>;
348
407
  acquireConnection(input: AcquireConnectionInput): Promise<AcquireConnectionResult>;
349
408
  /** 世代谓词命中 = true;0 行 = 已失权。 */
350
409
  heartbeatConnection(input: HeartbeatConnectionInput): Promise<boolean>;
351
410
  releaseConnection(deviceId: string, generation: number): Promise<boolean>;
352
411
  bindSession(input: BindSessionInput): Promise<BindSessionResult>;
412
+ /** O4 显式换绑(v2 稿 §2 步 2/3/5):目标设备门 + 前态 CAS + `session_rebound` 审计行同一事务。
413
+ * 活跃 run 门**不在这里**(run store 是另一只店)—— 由 HTTP 层在调用前判(v2 稿 §2 步 4)。 */
414
+ rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
353
415
  getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
354
416
  /** purge coordinator 座(§6 注②:core 的会话删除是**逐 store 显式调用**,不是 DB 自动扫;
355
417
  * 只建表不接 purge = 删会话后绑定残留 ⇒ sessionId 重用致旧设备粘连/claim conflict/隐私残留)。 */
@@ -380,11 +442,13 @@ export declare class InMemoryDeviceStore implements DeviceStore {
380
442
  readDeviceForAdmission(deviceId: string, owner: DeviceOwner): Promise<DeviceAdmissionView | null>;
381
443
  getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
382
444
  listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
445
+ listDevicesUnscoped(): Promise<DeviceRow[]>;
383
446
  revokeDevice(input: RevokeDeviceInput): Promise<RevokeDeviceResult>;
384
447
  acquireConnection(input: AcquireConnectionInput): Promise<AcquireConnectionResult>;
385
448
  heartbeatConnection(input: HeartbeatConnectionInput): Promise<boolean>;
386
449
  releaseConnection(deviceId: string, generation: number): Promise<boolean>;
387
450
  bindSession(input: BindSessionInput): Promise<BindSessionResult>;
451
+ rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
388
452
  getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
389
453
  deleteByRootSession(rootSessionId: string): Promise<number>;
390
454
  appendAudit(input: AppendAuditInput): Promise<void>;
@@ -10,6 +10,8 @@ export const DEVICE_ERROR_CODES = {
10
10
  BUSY: "device.busy",
11
11
  ENROLLMENT_INVALID: "device.enrollment_invalid",
12
12
  NOT_FOUND: "not_found.device",
13
+ REBIND_REV_CONFLICT: "device.rebind_rev_conflict",
14
+ REBIND_ACTIVE_RUN: "device.rebind_active_run",
13
15
  };
14
16
  export const DEVICE_RATE_LIMIT_CODE = "limit.rate_exceeded";
15
17
  export const DEVICE_DRAINING_CODE = "draining";
@@ -24,6 +26,8 @@ export const DEVICE_ERROR_STATUS = {
24
26
  "device.busy": 429,
25
27
  "device.enrollment_invalid": 403,
26
28
  "not_found.device": 404,
29
+ "device.rebind_rev_conflict": 409,
30
+ "device.rebind_active_run": 409,
27
31
  "limit.rate_exceeded": 429,
28
32
  "draining": 503,
29
33
  };
@@ -36,6 +40,7 @@ export const DEVICE_AUDIT_EVENTS = [
36
40
  "device_identity_mismatch",
37
41
  "device_superseded",
38
42
  "device_attached_elsewhere",
43
+ "session_rebound",
39
44
  ];
40
45
  export const DEVICE_PLATFORM_OS = ["darwin", "linux"];
41
46
  export const DEVICE_STATUSES = ["active", "revoked"];
@@ -114,6 +119,14 @@ export function assertRevokeShape(input) {
114
119
  assertBoundedText("revokedBy", input.revokedBy, DEVICE_TEXT_LIMITS.actor);
115
120
  assertBoundedText("revokeReason", input.reason ?? "", DEVICE_TEXT_LIMITS.revokeReason);
116
121
  }
122
+ export function assertRebindShape(input) {
123
+ assertRootSessionIdShape(input.rootSessionId);
124
+ assertDeviceIdShape(input.toDeviceId);
125
+ assertDeviceOwnerShape(input.owner);
126
+ if (!Number.isInteger(input.expectedRev) || input.expectedRev < 0) {
127
+ throw new Error(`device-store: expectedRev must be a non-negative integer (got ${input.expectedRev}) — it enters a byte-exact BIGINT equality predicate`);
128
+ }
129
+ }
117
130
  export function assertAcquireShape(input) {
118
131
  assertDeviceIdShape(input.deviceId);
119
132
  assertDeviceOwnerShape(input.owner);
@@ -284,6 +297,9 @@ export class InMemoryDeviceStore {
284
297
  assertDeviceOwnerShape(owner);
285
298
  return [...this.devices.values()].filter((d) => sameOwner(d.owner, owner)).sort((a, b) => (a.deviceId < b.deviceId ? -1 : 1)).map((d) => this.snapshotDevice(d));
286
299
  }
300
+ async listDevicesUnscoped() {
301
+ return [...this.devices.values()].sort((a, b) => (a.deviceId < b.deviceId ? -1 : 1)).map((d) => this.snapshotDevice(d));
302
+ }
287
303
  async revokeDevice(input) {
288
304
  assertRevokeShape(input);
289
305
  const row = this.devices.get(input.deviceId);
@@ -365,6 +381,25 @@ export class InMemoryDeviceStore {
365
381
  this.sessions.set(row.rootSessionId, row);
366
382
  return { outcome: "bound", row: this.snapshotSession(row) };
367
383
  }
384
+ async rebindSession(input) {
385
+ assertRebindShape(input);
386
+ const device = this.devices.get(input.toDeviceId);
387
+ if (!device || !sameOwner(device.owner, input.owner) || device.status !== "active")
388
+ return { outcome: "device_not_found" };
389
+ const row = this.sessions.get(input.rootSessionId);
390
+ if (!row || !sameOwner(row.owner, input.owner))
391
+ return { outcome: "binding_not_found" };
392
+ if (row.deviceId === input.toDeviceId && row.rev === input.expectedRev)
393
+ return { outcome: "idempotent", row: this.snapshotSession(row) };
394
+ if (row.rev !== input.expectedRev)
395
+ return { outcome: "rev_conflict", row: this.snapshotSession(row) };
396
+ const from = row.deviceId;
397
+ row.deviceId = input.toDeviceId;
398
+ row.boundAtMs = this.now();
399
+ row.rev += 1;
400
+ this.pushAudit({ event: "session_rebound", deviceId: input.toDeviceId, owner: row.owner, detail: { from, to: input.toDeviceId, rev: row.rev } });
401
+ return { outcome: "rebound", row: this.snapshotSession(row) };
402
+ }
368
403
  async getSessionBinding(rootSessionId) {
369
404
  assertRootSessionIdShape(rootSessionId);
370
405
  const row = this.sessions.get(rootSessionId);
@@ -56,6 +56,14 @@ export type DeviceDispatchInput = {
56
56
  [K in DeviceInstructionKind]: {
57
57
  rootSessionId: string;
58
58
  requester: DeviceOwner;
59
+ /**
60
+ * O4 §3 投递门**身份重断言**(codex 轮2 R2-[high],红先 R4b 实证):调用方 env **铸造时**钉住的
61
+ * 设备。准入在 dispatch 内解出的是**当前**绑定 —— 显式换绑与在飞 turn 竞态时两者可以不同,而
62
+ * adapter 的 connect 身份检查罩不住已连接 env 的后续调用(它们不再过 connect)。差异 ⇒ 恒拒
63
+ * `device.identity_mismatch`(cwd/能力面是从铸造设备读的,静默改道 = 在错误机器的工作区上执行)。
64
+ * **必填**:可选会让漏传静默([ref] `originTaskId` 同判)。
65
+ */
66
+ expectedDeviceId: string;
59
67
  kind: K;
60
68
  args: DeviceInstructionArgsByKind[K];
61
69
  cwd?: string;
@@ -811,6 +811,12 @@ export function createDeviceWsHub(options) {
811
811
  if (abortedNow())
812
812
  return abortedBeforeCommit();
813
813
  const { deviceId, connGeneration } = admitted.value;
814
+ if (deviceId !== input.expectedDeviceId) {
815
+ return {
816
+ kind: "rejected",
817
+ reject: rejection(DEVICE_ERROR_CODES.IDENTITY_MISMATCH, `this session is now bound to a different device (${deviceId}) than the one this instruction's environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (O4 v2 §3)`),
818
+ };
819
+ }
814
820
  const conn = conns.get(deviceId);
815
821
  if (!conn || conn.phase !== "active" || conn.goodbye) {
816
822
  return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.OFFLINE, "the bound device has no dispatchable connection on this replica") };
@@ -108,6 +108,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
108
108
  sendJson(res, 200, {
109
109
  pending: (await cs.listPending(scope)).map((r) => projectPendingForWire(r, deps.config)),
110
110
  ...(deps.toolApproval ? { livePending: deps.toolApproval.listLivePending(scope) } : {}),
111
+ ...(deps.approvalAskAudit ? { crashConverged: deps.approvalAskAudit.listCrashConverged(scope) } : {}),
111
112
  });
112
113
  return;
113
114
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * O4 设备换绑 wire + S-4 additive 投影(DEBTS [ref];设计真源 = sema-internal
3
+ * `server/designs/2026-08-30-o4-device-rebind-wire-v2.md`,冻结稿)—— `/v1/devices/*` 管理面的
4
+ * **首批三动词**(capabilities.ts 那条「车C 的 `/v1/devices/*`」预留注所指的族,本文件开族):
5
+ *
6
+ * · `GET /v1/devices` —— 设备列表(owner 域过滤;explicit operator 全量)。行 =
7
+ * `{ deviceId, name?, status: "online"|"offline", lastSeenAt? }`(v2 稿 §4 冻结形;`status` 由
8
+ * **hub 在场性**判 —— v1 部署形 = 单副本(基线稿 §4.6),汇聚端的活连接集就是本副本的 presence
9
+ * 真源;`name` = displayName,空串诚实缺席;`lastSeenAt` ISO-8601)。**吊销行不列**:revoked 是
10
+ * 终态(重注册 = 新 deviceId),列出来只会被壳当换绑目标然后撞 404。
11
+ * · `GET /v1/devices/sessions/:rootSessionId` —— S-4 绑定读面(壳换绑 UX 的全部材料):
12
+ * `{ rootSessionId, boundDevice: { deviceId, rev, boundAt } }`。稿 §4 允许「并入 session 读面」或
13
+ * 「独立读面」两形,选**独立读面**:sessions 的 GET 面承诺「无参请求字节不变」(routes/sessions.ts
14
+ * 的 additive contract 注),且 audit 投影由 boot/session-faces 组装、不持 device 店 —— 并入即跨
15
+ * 装配缝加一次店读;独立读面把 device wire 族收在一个文件里,户口与门序自洽。
16
+ * · `POST /v1/devices/sessions/:rootSessionId/rebind` —— 显式换绑动词(v2 稿 §2 门序五步,逐字):
17
+ * ① gatedPrincipal + owner 门(非属主 404 反枚举同串,[ref] 纪律)
18
+ * ② 目标设备在场门(不存在 / 不是该域的 / 已吊销 ⇒ 404 **同串** —— 设备 id 也是不可枚举面)
19
+ * ③ 无活跃 run 门(**CAS 前判、CAS 语句不兼职**;durable 判据 = run store 的 session claim
20
+ * `getActiveTaskId`,hub 内存在途计数不作门 —— 内存态跨副本不可见;拒 = 409
21
+ * `device.rebind_active_run` 体带 `activeTaskId`)
22
+ * ④ 前态 CAS(店内单事务:`rev` 恒变列 + `session_rebound` 审计行;输 = 409
23
+ * `device.rebind_rev_conflict` 体带 `currentRev` —— [ref] currentPending 哲学,壳一跳重定位)
24
+ * ⑤ 赢 = 200 `{ rootSessionId, deviceId, rev, workspaceCarryover: "none" }`。
25
+ * 幂等形:toDeviceId ≡ 当前 ∧ expectedRev ≡ 当前 rev ⇒ 200 回显不加 rev(真幂等,不写审计)。
26
+ * `workspaceCarryover: "none"` 是 **wire 冻结字面**(v2 稿 §5):换绑只改路由,不搬字节 —— 壳据
27
+ * 此渲「新设备上是全新工作区」的诚实披露;两个 200 形恒带。
28
+ *
29
+ * ── 调用方域(owner domain)的唯一铸造点 ─────────────────────────────────────────────────────────
30
+ * device lane 的三表自持 O11 复合身份 `{owner_tenant, owner_subject}`(device-store.ts 头注;全局
31
+ * principal 一个字节不动)。基线稿 §11-O11 的落法:「认证需在 SSO 校验点把 tenant+subject 传到 device
32
+ * placement 校验腿」——对位到本仓的真源即:subject = `gatedPrincipal`(verified 链),tenant =
33
+ * `ssoVerifiedScope`(registry-JWT 的 `scope` claim,security.ts:同为签名真源、非自报头)。两者齐备
34
+ * 才构成一个 device 域;缺 tenant claim 的凭据(纯 principal 头 / 静态 token 直连)**判不出域** ⇒
35
+ * 非 operator 一律响亮 403(fail-closed + loud,[ref]:静默空列表是假健康)——基线稿对 enrollment 的
36
+ * 同向裁定是「缺 tenant/global scope 明确拒注册」。将来的 enrollment/管理端点(车C 余件)**必须**从
37
+ * 这同一个函数取域,不得手抄第二份派生。
38
+ *
39
+ * ── 反枚举 404 的单一铸造点 ──────────────────────────────────────────────────────────────────────
40
+ * 「绑定不在 / 不是你的 / 目标设备不在 / 不是该域的 / 已吊销」五臂共用 {@link sendDeviceNotFound} ——
41
+ * 判别位在一个函数里就消失,memory `anti-enum-404-ambiguity` 的教训(判别位留在上层 = 迟早有一个
42
+ * 调用点忘记归一)在结构上关掉。码/文案与 device-enrollment.ts 的 `rejectNotFound` 同串
43
+ * (`not_found.device` + "device not found")。
44
+ *
45
+ * ── 门与失败方向 ────────────────────────────────────────────────────────────────────────────────
46
+ * 活跃 run 门的 store 读**不吞错**(server.ts 有两处 `getActiveTaskId(...).catch(() => undefined)`,
47
+ * 那是观测面;这里是门 —— store 打不通就 500,绝不当「没有活跃 run」放行,[ref])。run store 缺席 =
48
+ * 501 `capability.run_store_required`(门无判据即拒,不是跳过)。
49
+ *
50
+ * billable = false 全族(纯管理/读面,零模型工作);刻意不吃 drain 503(读面与管理动作在排空期
51
+ * 照常有效 —— 换绑本身正是「设备要换了」的运维动作)。错误码登记:附录 A + error-code-catalog-live
52
+ * (send 点全部写**字面量** + `satisfies DeviceErrorCode` 钉闭集 —— 目录门扫的是字面量,闭集纪律由
53
+ * 编译器执行,两道门同时成立;device-store.ts「禁手抄」条的本义是禁**脱离闭集**的字面量)。
54
+ */
55
+ import type { IncomingMessage, ServerResponse } from "node:http";
56
+ import type { RouteCtx } from "../route-ctx.js";
57
+ import { type DeviceOwner } from "../../device-store.js";
58
+ /**
59
+ * 调用方 device 域的**唯一铸造点**(文件头注)。undefined = 判不出域(无 principal,或无 tenant
60
+ * claim)——消费点必须走 explicit operator 或响亮 403,禁静默空视图。
61
+ */
62
+ export declare function deviceOwnerDomainOf(req: IncomingMessage, config: RouteCtx["deps"]["config"]): DeviceOwner | undefined;
63
+ export declare function handleDevices(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
64
+ //# sourceMappingURL=devices.d.ts.map
@@ -0,0 +1,173 @@
1
+ import { sendError, sendJson } from "../send.js";
2
+ import { explicitOperatorOk, gatedPrincipal } from "../principal-gate.js";
3
+ import { ssoVerifiedScope } from "../../security.js";
4
+ import { assertDeviceIdShape, assertRootSessionIdShape, sameDeviceOwner, } from "../../device-store.js";
5
+ const DEVICE_NOT_FOUND_MESSAGE = "device not found";
6
+ const NO_DEVICE_DOMAIN_MESSAGE = "device management needs a caller domain: this credential carries no verified tenant scope claim (registry auth-bridge JWT `scope`) and is not an explicit operator — connect through the registry auth bridge, or configure OPERATOR_PRINCIPALS";
7
+ const DEVICE_LANE_REQUIRED_MESSAGE = "device management requires the device execution lane (REMOTE_EXEC=device with a SQL store backend) — this deployment has no device lane wired";
8
+ const _deviceRouteCodesPinned = ["not_found.device", "device.rebind_rev_conflict", "device.rebind_active_run"];
9
+ void _deviceRouteCodesPinned;
10
+ function sendDeviceNotFound(res) {
11
+ sendError(res, 404, "not_found.device", DEVICE_NOT_FOUND_MESSAGE);
12
+ }
13
+ export function deviceOwnerDomainOf(req, config) {
14
+ const subject = gatedPrincipal(req, config);
15
+ if (subject === undefined)
16
+ return undefined;
17
+ const tenant = ssoVerifiedScope(req);
18
+ if (tenant === undefined)
19
+ return undefined;
20
+ return { tenant, subject };
21
+ }
22
+ function wellFormedKey(assert, v) {
23
+ try {
24
+ assert(v);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function deviceListRow(row, online) {
32
+ return {
33
+ deviceId: row.deviceId,
34
+ ...(row.displayName !== "" ? { name: row.displayName } : {}),
35
+ status: online.has(row.deviceId) ? "online" : "offline",
36
+ ...(row.lastSeenAtMs !== null ? { lastSeenAt: new Date(row.lastSeenAtMs).toISOString() } : {}),
37
+ };
38
+ }
39
+ function boundDeviceOf(row) {
40
+ return { deviceId: row.deviceId, rev: row.rev, boundAt: new Date(row.boundAtMs).toISOString() };
41
+ }
42
+ export async function handleDevices(req, res, url, ctx) {
43
+ const miss = { fell: false };
44
+ await handleDevicesBody(req, res, url, ctx, miss);
45
+ return !miss.fell;
46
+ }
47
+ async function handleDevicesBody(req, res, url, ctx, miss) {
48
+ const { deps } = ctx;
49
+ const { readJson, safeDecode } = ctx.helpers;
50
+ if (url !== "/v1/devices" && !url.startsWith("/v1/devices/")) {
51
+ miss.fell = true;
52
+ return;
53
+ }
54
+ const listHit = req.method === "GET" && url === "/v1/devices";
55
+ const readM = req.method === "GET" ? /^\/v1\/devices\/sessions\/([^/]+)$/.exec(url) : null;
56
+ const rebindM = req.method === "POST" ? /^\/v1\/devices\/sessions\/([^/]+)\/rebind$/.exec(url) : null;
57
+ if (!listHit && !readM && !rebindM) {
58
+ miss.fell = true;
59
+ return;
60
+ }
61
+ const principal = gatedPrincipal(req, deps.config);
62
+ if (deps.config.requirePrincipal && principal === undefined) {
63
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
64
+ return;
65
+ }
66
+ const store = deps.deviceStore;
67
+ const hub = deps.deviceHub;
68
+ if (!store || !hub) {
69
+ sendError(res, 501, "capability.device_lane_required", DEVICE_LANE_REQUIRED_MESSAGE);
70
+ return;
71
+ }
72
+ const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
73
+ const domain = deviceOwnerDomainOf(req, deps.config);
74
+ if (listHit) {
75
+ if (!operator && domain === undefined) {
76
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
77
+ return;
78
+ }
79
+ const rows = operator ? await store.listDevicesUnscoped() : await store.listDevicesByOwner(domain);
80
+ const online = new Set(hub.connectedDeviceIds());
81
+ sendJson(res, 200, { devices: rows.filter((r) => r.status !== "revoked").map((r) => deviceListRow(r, online)) });
82
+ return;
83
+ }
84
+ const seg = (readM ?? rebindM)[1];
85
+ const decoded = safeDecode(seg);
86
+ if (decoded === null) {
87
+ sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
88
+ return;
89
+ }
90
+ if (!wellFormedKey(assertRootSessionIdShape, decoded)) {
91
+ sendError(res, 400, "request.id_invalid", "invalid session id segment (device binding keys are byte-exact: non-empty, no control characters or surrounding whitespace, at most 128 bytes)");
92
+ return;
93
+ }
94
+ const rootSessionId = decoded;
95
+ if (readM) {
96
+ if (!operator && domain === undefined) {
97
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
98
+ return;
99
+ }
100
+ const binding = await store.getSessionBinding(rootSessionId);
101
+ if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
102
+ sendDeviceNotFound(res);
103
+ return;
104
+ }
105
+ sendJson(res, 200, { rootSessionId, boundDevice: boundDeviceOf(binding) });
106
+ return;
107
+ }
108
+ if (!operator && domain === undefined) {
109
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
110
+ return;
111
+ }
112
+ const binding = await store.getSessionBinding(rootSessionId);
113
+ if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
114
+ sendDeviceNotFound(res);
115
+ return;
116
+ }
117
+ let body;
118
+ try {
119
+ body = (await readJson(req));
120
+ }
121
+ catch {
122
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
123
+ return;
124
+ }
125
+ if (typeof body !== "object" || body === null || typeof body.toDeviceId !== "string" || body.expectedRev === undefined) {
126
+ sendError(res, 400, "request.body_shape", "body must be { toDeviceId: string, expectedRev: number } — expectedRev is the binding rev read from GET /v1/devices/sessions/{rootSessionId} (explicit prior state; two concurrent operators must not silently stomp each other)");
127
+ return;
128
+ }
129
+ if (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0) {
130
+ sendError(res, 400, "request.field_invalid", "expectedRev must be a non-negative integer");
131
+ return;
132
+ }
133
+ if (!wellFormedKey(assertDeviceIdShape, body.toDeviceId)) {
134
+ sendError(res, 400, "request.field_invalid", "toDeviceId is not a valid device id (byte-exact key: non-empty, no control characters or surrounding whitespace, at most 64 bytes)");
135
+ return;
136
+ }
137
+ const toDeviceId = body.toDeviceId;
138
+ const expectedRev = body.expectedRev;
139
+ const target = await store.readDeviceForAdmission(toDeviceId, binding.owner);
140
+ if (!target || target.row.status !== "active") {
141
+ sendDeviceNotFound(res);
142
+ return;
143
+ }
144
+ if (!deps.runStore) {
145
+ sendError(res, 501, "capability.run_store_required", "device rebind needs the run store to judge the no-active-run door (a durable, cross-replica criterion) — this deployment has no run store wired");
146
+ return;
147
+ }
148
+ const activeTaskId = await deps.runStore.getActiveTaskId(rootSessionId);
149
+ if (activeTaskId) {
150
+ sendError(res, 409, "device.rebind_active_run", "this session has a non-terminal run — rebinding now would leave in-flight work addressed at another workspace; cancel the run (or let it finish), then retry", { activeTaskId });
151
+ return;
152
+ }
153
+ const result = await store.rebindSession({ rootSessionId, toDeviceId, expectedRev, owner: binding.owner });
154
+ switch (result.outcome) {
155
+ case "rebound":
156
+ case "idempotent":
157
+ sendJson(res, 200, { rootSessionId, deviceId: result.row.deviceId, rev: result.row.rev, workspaceCarryover: "none" });
158
+ return;
159
+ case "rev_conflict":
160
+ sendError(res, 409, "device.rebind_rev_conflict", "expectedRev does not match the binding's current rev — the session↔device binding changed since it was read; re-read it and retry with currentRev", { currentRev: result.row.rev });
161
+ return;
162
+ case "binding_not_found":
163
+ case "device_not_found":
164
+ sendDeviceNotFound(res);
165
+ return;
166
+ default: {
167
+ const unhandled = result;
168
+ void unhandled;
169
+ throw new Error("device rebind: unknown store outcome");
170
+ }
171
+ }
172
+ }
173
+ //# sourceMappingURL=devices.js.map
@@ -18,6 +18,7 @@ import { type FleetEventBus } from "../fleet/fleet-bus.js";
18
18
  import type { ElicitationCoordinator } from "../elicitation.js";
19
19
  import type { QuestionCoordinator } from "../question.js";
20
20
  import { type ParkedAskRedeem, type ToolApprovalCoordinator } from "../tool-approval.js";
21
+ import type { ApprovalAskAuditReadFace } from "../approval-ask-audit-store.js";
21
22
  import type { PlanCacheProbe } from "../plan-cache-probe.js";
22
23
  import type { Logger } from "../observability/logger.js";
23
24
  import type { Metrics } from "../observability/metrics.js";
@@ -204,6 +205,11 @@ export interface ServiceCoordinatorDeps {
204
205
  * `POST /v1/tool-approvals/:id/respond` + the per-run ALS wrap on the SYNC streaming leg (live-only by design —
205
206
  * every other leg keeps core's fail-closed headless auto-deny, [ref]⑤/[ref]④). */
206
207
  toolApproval?: ToolApprovalCoordinator;
208
+ /** [ref] 裁 (c) 审计半场:local 车道的 File ask **审计**读面(`GET /v1/approvals` 的 additive 键
209
+ * `crashConverged` 数据源)。在场 ⇔ `DB_BACKEND=local` ∧ 审批面开着(装配 boot/coordinators.ts);
210
+ * SQL 车道恒缺席(那里有真 ask 店,[ref])—— 键缺席 = 本部署无此面,`[]` = 有面且无崩溃残账,
211
+ * 两义与 `livePending` 同律可判别。 */
212
+ approvalAskAudit?: ApprovalAskAuditReadFace;
207
213
  /** SVC-5 ([ref] CORE-5 #6): the process-local registry of STEERABLE workflow-agent handles
208
214
  * (`ctx.agentStream`) live on THIS replica. Enables POST /v1/workflows/:id/agents/:label/steer. See
209
215
  * workflow-agent-steer.ts for the handle-visibility seam status (the registry is real + ready; core does not
@@ -263,6 +269,14 @@ export interface ServiceSeamDeps {
263
269
  * `REMOTE_EXEC=device` 拒启门对齐)属**车A-4/端点车**。在那之前本键只有测试口消费,不是死枝。
264
270
  */
265
271
  deviceHub?: import("../device-ws-hub.js").DeviceWsHub;
272
+ /**
273
+ * O4/S-4([ref]):device lane 四表店 —— `/v1/devices/*` 管理面(routes/devices.ts:列表投影 /
274
+ * 绑定读面 / 显式换绑动词)的持久真源。main.ts 与 {@link deviceHub} 同源装配
275
+ * (`deviceLane?.store`,`REMOTE_EXEC=device` 时在场);缺席 ⇒ 该族路由 501
276
+ * `capability.device_lane_required`(管理面在场性由路由自身答 —— capabilities 刻意不加新位,
277
+ * v2 换绑稿 §7)。
278
+ */
279
+ deviceStore?: import("../device-store.js").DeviceStore;
266
280
  /** Audit回溯: current context (+ summary) for a session, plus its owner. Enables GET /v1/sessions/:id. */
267
281
  sessionAudit?: (sessionId: string) => Promise<({
268
282
  owner: string | null;
@@ -43,6 +43,7 @@ import { handleDiagnostics } from "./routes/diagnostics.js";
43
43
  import { handleAdoption } from "./routes/adoption.js";
44
44
  import { handleRetention } from "./routes/retention-ops.js";
45
45
  import { handleAdminDrain } from "./routes/admin-drain.js";
46
+ import { handleDevices } from "./routes/devices.js";
46
47
  import { handleAdminConfigRefresh } from "./routes/admin-config-refresh.js";
47
48
  import { handleConfigCatalog } from "./routes/config-catalog.js";
48
49
  import { handleMemoryOptOut } from "./routes/admin-memory-optout.js";
@@ -149,6 +150,7 @@ const ROUTE_DOMAINS = [
149
150
  handleAdoption,
150
151
  handleRetention,
151
152
  handleAdminDrain,
153
+ handleDevices,
152
154
  handleAdminConfigRefresh,
153
155
  handleConfigCatalog,
154
156
  handleMemoryOptOut,
@@ -466,6 +468,8 @@ export function createHttpServer(rawDeps) {
466
468
  return;
467
469
  if (await handleAdminDrain(req, res, url, ctx))
468
470
  return;
471
+ if (await handleDevices(req, res, url, ctx))
472
+ return;
469
473
  if (await handleAdminConfigRefresh(req, res, url, ctx))
470
474
  return;
471
475
  if (await handleConfigCatalog(req, res, url, ctx))
@@ -1667,6 +1671,7 @@ export function createHttpServer(rawDeps) {
1667
1671
  abortSignal: cancelCtrl.signal,
1668
1672
  sessionId,
1669
1673
  emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
1674
+ emitRevoke: (f) => approvalEmit(f),
1670
1675
  legKey,
1671
1676
  ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
1672
1677
  }, driveResume)
@@ -2279,7 +2284,8 @@ export function isCredentialGatedRewrite(method, url) {
2279
2284
  url === "/v1/memory/import" ||
2280
2285
  url === "/v1/memory/export" ||
2281
2286
  url === "/v1/memory/erase" ||
2282
- url.startsWith("/v1/memory/origin/entries/"));
2287
+ url.startsWith("/v1/memory/origin/entries/") ||
2288
+ url.startsWith("/v1/devices/"));
2283
2289
  }
2284
2290
  if (m === "PUT")
2285
2291
  return url === "/v1/admin/memory-optout" || url.startsWith("/v1/admin/memory-optout/");
@@ -2289,7 +2295,9 @@ export function isCredentialGatedRewrite(method, url) {
2289
2295
  return (/^\/v1\/memory\/entries\/[^/]+\/provenance$/.test(url) ||
2290
2296
  url === "/v1/memory/origin/external" ||
2291
2297
  url === "/v1/memory/origin/clearances" ||
2292
- url === "/v1/admin/memory-optout");
2298
+ url === "/v1/admin/memory-optout" ||
2299
+ url === "/v1/devices" ||
2300
+ /^\/v1\/devices\/sessions\/[^/]+$/.test(url));
2293
2301
  }
2294
2302
  return false;
2295
2303
  }
@@ -2334,6 +2342,8 @@ const ROUTE_LABEL_PATTERNS = [
2334
2342
  [/^\/v1\/tasks\/[^/]+\/tool-results\/[^/]+$/, "/v1/tasks/:id/tool-results/:ref"],
2335
2343
  [/^\/v1\/tasks\/[^/]+\/(turns|stream|artifacts)$/, "/v1/tasks/:id/$sub"],
2336
2344
  [/^\/v1\/leader\/[^/]+$/, "/v1/leader/:id"],
2345
+ [/^\/v1\/devices\/sessions\/[^/]+\/rebind$/, "/v1/devices/sessions/:id/rebind"],
2346
+ [/^\/v1\/devices\/sessions\/[^/]+$/, "/v1/devices/sessions/:id"],
2337
2347
  [/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
2338
2348
  [/^\/v1\/capabilities\/scenarios\/[^/]+$/, "/v1/capabilities/scenarios/:name"],
2339
2349
  [/^\/v1\/adoption\/[^/]+$/, "/v1/adoption/:id"],
@@ -2352,6 +2362,7 @@ const ROUTE_LABEL_LITERALS = new Set([
2352
2362
  "/v1/assistant/inbox", "/v1/assistant/tasks", "/v1/usage", "/v1/policy", "/v1/capabilities",
2353
2363
  "/v1/images", "/v1/images/bakes", "/v1/images/bakes/claim", "/v1/images/select", "/v1/images/register",
2354
2364
  "/v1/workflows", "/v1/attachments", "/v1/leader", "/v1/outcomes", "/v1/side-query",
2365
+ "/v1/devices",
2355
2366
  "/v1/fleet/stream",
2356
2367
  "/v1/memory/export", "/v1/sendfile-links",
2357
2368
  "/v1/memory/import",
package/dist/main.js CHANGED
@@ -165,7 +165,7 @@ async function main() {
165
165
  };
166
166
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
167
167
  let parkedAskRedeem;
168
- const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
168
+ const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, approvalAskAudit, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
169
169
  config, logger, backend, sendUserFileTaskEnvs, ruleConsent,
170
170
  getParkedRedeem: () => parkedAskRedeem,
171
171
  ruleScopeRootFor: (sessionId) => cardRuleScopeRoot(sessionId !== undefined ? perSessionCwd.get(sessionId) : undefined, config),
@@ -591,6 +591,7 @@ async function main() {
591
591
  elicitation: elicitation ? elicitation : undefined,
592
592
  question: question ? question : undefined,
593
593
  toolApproval: toolApproval ? toolApproval : undefined,
594
+ approvalAskAudit: approvalAskAudit ? approvalAskAudit : undefined,
594
595
  workflowAgentRegistry: workflowAgentRegistry ? workflowAgentRegistry : undefined,
595
596
  subagentSteerRegistry,
596
597
  workflowCompletionInbox: workflowCompletionInbox ? workflowCompletionInbox : undefined,
@@ -604,6 +605,7 @@ async function main() {
604
605
  };
605
606
  const seams = {
606
607
  deviceHub: deviceLane?.hub,
608
+ deviceStore: deviceLane?.store,
607
609
  sessionAudit,
608
610
  routeJudgeBrain: brain,
609
611
  purgeSession: purgeSession ? purgeSession : undefined,