@sema-agent/server 7.54.0 → 7.56.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 (76) hide show
  1. package/MIGRATION.md +3 -3
  2. package/README.md +12 -6
  3. package/README.zh-CN.md +9 -5
  4. package/USAGE.md +108 -52
  5. package/deploy/sema-up/chart/values.yaml +1 -1
  6. package/dist/approval-ask-audit-store.d.ts +129 -0
  7. package/dist/approval-ask-audit-store.js +284 -0
  8. package/dist/approval-card.d.ts +18 -6
  9. package/dist/approval-card.js +2 -2
  10. package/dist/approval-content-kind.d.ts +22 -0
  11. package/dist/approval-content-kind.js +5 -0
  12. package/dist/approval-reconciler.d.ts +2 -1
  13. package/dist/approval-reconciler.js +1 -1
  14. package/dist/boot/coordinators.d.ts +2 -0
  15. package/dist/boot/coordinators.js +18 -1
  16. package/dist/boot/leader.d.ts +21 -0
  17. package/dist/boot/leader.js +6 -0
  18. package/dist/boot/reapers.d.ts +2 -1
  19. package/dist/boot/runtime-caps.d.ts +2 -1
  20. package/dist/boot/runtime-caps.js +3 -1
  21. package/dist/boot/stores.js +8 -1
  22. package/dist/config-center/types.d.ts +2 -1
  23. package/dist/config-provider.js +1 -0
  24. package/dist/device-store.d.ts +66 -2
  25. package/dist/device-store.js +35 -0
  26. package/dist/device-ws-hub.d.ts +8 -0
  27. package/dist/device-ws-hub.js +6 -0
  28. package/dist/http/route-ctx.d.ts +15 -3
  29. package/dist/http/routes/approvals-assistant.js +3 -2
  30. package/dist/http/routes/devices.d.ts +64 -0
  31. package/dist/http/routes/devices.js +173 -0
  32. package/dist/http/routes/leader.js +2 -2
  33. package/dist/http/routes/workflows.js +1 -1
  34. package/dist/http/server.d.ts +14 -0
  35. package/dist/http/server.js +30 -5
  36. package/dist/leader/endpoint.js +5 -4
  37. package/dist/leader/wire.d.ts +50 -0
  38. package/dist/leader/wire.js +19 -7
  39. package/dist/main.js +4 -2
  40. package/dist/observability/fail-open.d.ts +7 -3
  41. package/dist/observability/fail-open.js +7 -3
  42. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  43. package/dist/plugins/approval-ask-store-memory.js +20 -3
  44. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  45. package/dist/plugins/approval-ask-store-sql.js +41 -10
  46. package/dist/plugins/checkpoint-store-sql.d.ts +12 -0
  47. package/dist/plugins/checkpoint-store-sql.js +4 -1
  48. package/dist/plugins/device-store-sql.d.ts +38 -1
  49. package/dist/plugins/device-store-sql.js +82 -2
  50. package/dist/plugins/file-run-store.js +2 -0
  51. package/dist/plugins/local-checkpoint-store.js +2 -0
  52. package/dist/plugins/local-session-store.d.ts +9 -9
  53. package/dist/plugins/local-session-store.js +5 -3
  54. package/dist/plugins/memory-run-store.js +2 -0
  55. package/dist/plugins/permission-rule-store-file.d.ts +22 -4
  56. package/dist/plugins/permission-rule-store-file.js +17 -8
  57. package/dist/plugins/permission-rule-store-sql.d.ts +60 -30
  58. package/dist/plugins/permission-rule-store-sql.js +23 -13
  59. package/dist/plugins/pg-session-storage.js +17 -13
  60. package/dist/plugins/remote-env-device.js +8 -1
  61. package/dist/plugins/run-store-sql.js +6 -4
  62. package/dist/plugins/sql-errors.d.ts +12 -0
  63. package/dist/plugins/sql-errors.js +10 -0
  64. package/dist/plugins/store-contracts.d.ts +4 -0
  65. package/dist/plugins/tidb-session-store.js +18 -14
  66. package/dist/rules-consent.d.ts +5 -4
  67. package/dist/rules-consent.js +5 -33
  68. package/dist/runs.js +1 -0
  69. package/dist/runtime-caps-resolver.js +3 -1
  70. package/dist/security.d.ts +7 -0
  71. package/dist/tool-approval.d.ts +66 -83
  72. package/dist/tool-approval.js +222 -186
  73. package/dist/trace/ledger-events.d.ts +9 -0
  74. package/package.json +3 -3
  75. package/skills/find-skills.md +1 -1
  76. package/skills/loop.md +1 -1
@@ -4,6 +4,8 @@ export function assertMemoryCapturePolicyWirable(input) {
4
4
  return;
5
5
  if (input.grantSourceWired)
6
6
  return;
7
+ if (input.centerEntitlementPresent === true)
8
+ return;
7
9
  throw new Error(`MEMORY_CAPTURE_POLICY=governed requires a per-principal memory opt-out verdict source, and this deployment wires NONE ` +
8
10
  `— under "governed" core REFUSES every session memory-capture opt-out whose verdict is absent (fail-closed, the posture's ` +
9
11
  `own contract), so a governed worker without a source would refuse every \`memory.capture:"off"\` declaration and the ` +
@@ -15,7 +17,7 @@ export function assertMemoryCapturePolicyWirable(input) {
15
17
  }
16
18
  export function createRuntimeCaps(ctx) {
17
19
  const { config, logger } = ctx;
18
- assertMemoryCapturePolicyWirable({ policy: config.memoryCapturePolicy, grantSourceWired: ctx.memoryOptOutGrant !== undefined });
20
+ assertMemoryCapturePolicyWirable({ policy: config.memoryCapturePolicy, grantSourceWired: ctx.memoryOptOutGrant !== undefined, centerEntitlementPresent: Boolean(config.configCenter && !config.configCenter.dryRun) });
19
21
  if (config.configCenter && !config.configCenter.dryRun && scopedTokenNeedsWorker(config.configCenter.token, config.configCenter.worker)) {
20
22
  logger.warn("runtime_caps_scoped_token_no_worker", {
21
23
  reason: "SEMA_REGISTRY_TOKEN is a worker-scoped wpt_ token but SEMA_REGISTRY_WORKER is unset → center 403s per-principal caps → ALL workflow self-orchestration will be fail-closed denied. Set SEMA_REGISTRY_WORKER (the orchestrator normally injects it) or use the full SERVICE_PULL_TOKEN.",
@@ -17,7 +17,8 @@ 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
+ import { assertDeviceAuditRebindEventSchema, ensurePgDeviceSchema, ensureTiDBDeviceSchema } from "../plugins/device-store-sql.js";
21
+ import { mysqlDriver, pgDriver } from "../plugins/sql-driver.js";
21
22
  import { assertCloudSnapshotBlobPosture, openStoreBackendWithFallback } from "../plugins/store-backend.js";
22
23
  import { buildMemoryRemoteLaneWarn, memoryEngineBackendFor, memoryEngineRemoteLanePosture } from "../memory-scope.js";
23
24
  import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
@@ -386,6 +387,12 @@ export async function openStores(ctx) {
386
387
  await ensurePgDeviceSchema(async (text, params) => pgPool.query(text, params));
387
388
  if (mysqlPool)
388
389
  await ensureTiDBDeviceSchema(mysqlPool);
390
+ if (config.remoteExec?.provider === "device") {
391
+ if (pgPool)
392
+ await assertDeviceAuditRebindEventSchema(pgDriver(pgPool));
393
+ else if (mysqlPool)
394
+ await assertDeviceAuditRebindEventSchema(mysqlDriver(mysqlPool));
395
+ }
389
396
  }
390
397
  const sessionStore = createSessionStore(config, backend, metrics);
391
398
  if (config.requirePrincipal && backend?.kind === "local") {
@@ -103,7 +103,8 @@ export interface CenterMcpServer {
103
103
  * 所以它的家在 center 下发这条腿;请求腿那半场的门与结构性兑现见 `task-mcp.ts`
104
104
  * 的 `assertRequestMcpContentOrigin` 顶注。
105
105
  *
106
- * ⚠️ **两条腿今天都到不了这里**([ref] / [ref]③ / [ref],2026-08-31 双绊线实测更正 —— 本注旧版那句
106
+ * **两条腿已通**([ref],settings-schema 1.4.0 补键 + config-provider 映射行,2026-09-02;双绊线翻正销账)。
107
+ * 病史([ref]③ / [ref]③ / [ref],2026-08-31 双绊线实测更正 —— 本注更旧版那句
107
108
  * 「远端 /effective.mcp 腿是直读 JSON,今天就通」**不成立**,doc-rot):settings-schema(原 registry-core)
108
109
  * 的 `McpServerSpec` 是默认 strip 的 zod object 且没有 `contentOrigin` 可选键,本地腿在 FileConfigStore 的
109
110
  * `parseDomainLoud(DOMAIN_SCHEMAS.mcp)`、远端腿在消费端边界 `readEffectiveWire`([ref] 裁B)**各被剥一次**。
@@ -145,6 +145,7 @@ export function mapToServiceEffective(eff, version) {
145
145
  enabled: s.enabled,
146
146
  ...(s.allowTools !== undefined ? { allowTools: s.allowTools } : {}),
147
147
  ...(s.elicitation !== undefined ? { elicitation: s.elicitation } : {}),
148
+ ...(s.contentOrigin !== undefined ? { contentOrigin: s.contentOrigin } : {}),
148
149
  };
149
150
  if (s.transport.kind === "stdio") {
150
151
  return {
@@ -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") };
@@ -198,10 +198,16 @@ export interface RouteLegs {
198
198
  status: number;
199
199
  body: object;
200
200
  }>;
201
- /** assistant 抢占腿的复位(gate-kind 守卫 + 驱动)。 */
201
+ /** assistant 抢占腿的复位(gate-kind 守卫 + 行级属主复核 + 驱动)。 */
202
202
  resumePreempted(sessionId: string, req: IncomingMessage | undefined,
203
203
  /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/resume` 腿传 `true`。 */
204
- acceptEarly?: boolean): Promise<{
204
+ acceptEarly?: boolean,
205
+ /** [ref]②([ref]):调用方已验身份的裁定 —— 腿内在被真正载入的 checkpoint 行上重跑属主判
206
+ * ([ref] R3 同形;缺席 = 匿名 dev 部署,不咬)。 */
207
+ decider?: {
208
+ principal?: string;
209
+ explicitOperator: boolean;
210
+ }): Promise<{
205
211
  status: number;
206
212
  body: object;
207
213
  }>;
@@ -221,7 +227,13 @@ export interface RouteLegs {
221
227
  principalPresent?: boolean;
222
228
  },
223
229
  /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/plan_review` 腿传 `true`。 */
224
- acceptEarly?: boolean): Promise<{
230
+ acceptEarly?: boolean,
231
+ /** [ref]②([ref]):调用方已验身份的裁定(直连门 = HMAC 验出的 principal)—— 腿内行级属主复核
232
+ * ([ref] R3 同形;缺席 = 匿名 dev 部署,不咬)。 */
233
+ decider?: {
234
+ principal?: string;
235
+ explicitOperator: boolean;
236
+ }): Promise<{
225
237
  status: number;
226
238
  body: object;
227
239
  }>;
@@ -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
  }
@@ -298,7 +299,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
298
299
  sendError(res, 404, "not_found.run", "task not found");
299
300
  return;
300
301
  }
301
- const out = await resumePreempted(run.sessionId, req, true);
302
+ const out = await resumePreempted(run.sessionId, req, true, { principal, explicitOperator });
302
303
  sendResumeOutcome(res, out, deps.logger);
303
304
  return;
304
305
  }
@@ -364,7 +365,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
364
365
  sendError(res, 404, "not_found.run", "task not found");
365
366
  return;
366
367
  }
367
- const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true);
368
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator });
368
369
  sendResumeOutcome(res, out, deps.logger);
369
370
  return;
370
371
  }
@@ -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
@@ -7,7 +7,7 @@ export async function handleLeader(req, res, url, ctx) {
7
7
  }
8
8
  async function handleLeaderBody(req, res, url, ctx, miss) {
9
9
  const { deps } = ctx;
10
- const { readJson, rateLimited, quotaExceeded, leaseDenied } = ctx.helpers;
10
+ const { readJson, rateLimited, quotaExceeded, leaseDenied, usageWindowDenied } = ctx.helpers;
11
11
  if (deps.leaderEndpoint) {
12
12
  const isLeaderPost = req.method === "POST" && url === "/v1/leader";
13
13
  const isLeaderGet = req.method === "GET" && /^\/v1\/leader\/[^/]+$/.test(url);
@@ -18,7 +18,7 @@ async function handleLeaderBody(req, res, url, ctx, miss) {
18
18
  }
19
19
  const requester = gatedPrincipal(req, deps.config) ?? null;
20
20
  if (isLeaderPost) {
21
- if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
21
+ if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)) || (await usageWindowDenied(req, res)))
22
22
  return;
23
23
  let body;
24
24
  try {
@@ -196,7 +196,7 @@ async function handleWorkflowAgentSteerBody(req, res, url, ctx, miss) {
196
196
  sendNotRunningWf(!wfRun
197
197
  ? "workflow agent is not running on this replica (no live handle)"
198
198
  : wfRun.status === "running"
199
- ? "workflow agent is active on another replica cross-replica live-steer is not yet supported"
199
+ ? "no live handle for this workflow agent on this replica — the run may be live on another replica (cross-replica live-steer is not yet supported), or this replica holds no steerable stream for it; read the workflow status faces for progress"
200
200
  : `workflow is ${wfRun.status} — agent is not running`);
201
201
  return;
202
202
  }