@sema-agent/server 7.87.3 → 7.88.1

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 (57) hide show
  1. package/dist/auth-bridge.js +5 -3
  2. package/dist/boot/device-lane.d.ts +12 -19
  3. package/dist/boot/device-lane.js +3 -13
  4. package/dist/boot/stage-01-config.js +2 -1
  5. package/dist/boot/stage-05-execution-env.js +1 -2
  6. package/dist/boot/stage-07-capability-layer.js +2 -1
  7. package/dist/boot/stage-09-leader.js +2 -1
  8. package/dist/boot/stage-10-http-server.js +4 -2
  9. package/dist/boot/stores.js +2 -1
  10. package/dist/brain.js +3 -2
  11. package/dist/config-catalog.js +4 -3
  12. package/dist/config-center/apply-effective.js +3 -2
  13. package/dist/config-center/http-client.d.ts +11 -2
  14. package/dist/config-center/http-client.js +1 -1
  15. package/dist/config-center/read-warnings.d.ts +1 -1
  16. package/dist/config-center/read-warnings.js +5 -3
  17. package/dist/config-center/types.d.ts +3 -1
  18. package/dist/config-invariants.d.ts +2 -2
  19. package/dist/config-invariants.js +12 -0
  20. package/dist/config-provider.d.ts +9 -1
  21. package/dist/config-provider.js +3 -1
  22. package/dist/config-types.d.ts +7 -0
  23. package/dist/config.js +28 -3
  24. package/dist/device-enrollment.d.ts +25 -4
  25. package/dist/device-enrollment.js +20 -2
  26. package/dist/device-protocol.d.ts +15 -0
  27. package/dist/device-protocol.js +2 -0
  28. package/dist/device-ws-hub.d.ts +11 -0
  29. package/dist/device-ws-hub.js +6 -0
  30. package/dist/fleet-client.js +2 -1
  31. package/dist/hooks/hook-llm.js +3 -2
  32. package/dist/http/admission.js +10 -1
  33. package/dist/http/device-placement.d.ts +177 -0
  34. package/dist/http/device-placement.js +148 -0
  35. package/dist/http/durable-run-leg.js +4 -0
  36. package/dist/http/resume-legs.js +4 -0
  37. package/dist/http/route-ctx.d.ts +8 -0
  38. package/dist/http/routes/capabilities.js +1 -1
  39. package/dist/http/routes/devices.d.ts +18 -8
  40. package/dist/http/routes/devices.js +175 -12
  41. package/dist/http/routes/tasks.js +10 -0
  42. package/dist/http/server.d.ts +13 -0
  43. package/dist/http/wire-types.d.ts +15 -0
  44. package/dist/model-route-url.d.ts +15 -0
  45. package/dist/model-route-url.js +19 -0
  46. package/dist/observability/fail-open.d.ts +11 -1
  47. package/dist/observability/fail-open.js +18 -2
  48. package/dist/request-key-closure.js +1 -1
  49. package/dist/run-local.js +2 -1
  50. package/dist/sealed-key.js +2 -1
  51. package/dist/security.d.ts +6 -0
  52. package/dist/security.js +3 -0
  53. package/dist/session-titler.js +4 -2
  54. package/dist/trace/redact.d.ts +20 -0
  55. package/dist/trace/redact.js +117 -26
  56. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +979 -0
  57. package/package.json +8 -3
@@ -4,6 +4,7 @@ import { fleetRunPublisher, fleetRunLabels } from "../fleet/fleet-bus.js";
4
4
  import { cascadeConfig, runMeta } from "./run-meta.js";
5
5
  import { resolveStreamApprovalGate } from "../tool-approval.js";
6
6
  import { buildActiveRunConflict } from "./active-run-conflict.js";
7
+ import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
7
8
  import { clearTurnActivity, readTurnActivityMs } from "../turn-activity.js";
8
9
  import { recordResourceWindow } from "../resource-window.js";
9
10
  export async function mintAndDriveDurableRun(ctx, runStore, args) {
@@ -24,6 +25,9 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
24
25
  }
25
26
  return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
26
27
  }
28
+ const placementReject = await commitPlacement(deps, runStore, taskId, args.prepared.bindingIntent);
29
+ if (placementReject)
30
+ return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
27
31
  if (deps.checkpointStore) {
28
32
  await deps.checkpointStore.putCtx(sessionId, { body: args.prepared.body, memoryScope: args.prepared.auth?.memoryScope });
29
33
  }
@@ -32,6 +32,7 @@ import { createSessionSyncLocal } from "./routes/session-sync.js";
32
32
  import { createImagesLocal } from "./routes/images.js";
33
33
  import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
34
34
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
35
+ import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
35
36
  import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
36
37
  function isCheckpointReopenedFailure(r) {
37
38
  const code = failureCodeOf(r.terminal);
@@ -394,6 +395,9 @@ export function createResumeLegs(ctx) {
394
395
  if (!created.ok) {
395
396
  return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
396
397
  }
398
+ const placementReject = await commitPlacement(deps, deps.runStore, freshId, undefined);
399
+ if (placementReject)
400
+ return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
397
401
  taskId = freshId;
398
402
  freshRunClaimed = true;
399
403
  }
@@ -127,6 +127,14 @@ export interface PreparedTaskSubmission {
127
127
  * 装配跑在这条腿的通告口开张之前,所以铸点交不出去 —— 由腿在 `registerEngineNoticeLeg` 那一拍
128
128
  * 经 `pending` 一起投(投递单点仍是那一只注册助手,不新开第二条机制)。缺席 = 本次装配没铸下任何通告。 */
129
129
  engineNotices?: readonly EngineNotice[];
130
+ /**
131
+ * S-470(device lane 首绑写协议,design §4.3.2 两阶段 placement)—— **已校验、未写库**的 placement 意图。
132
+ *
133
+ * 缺席的两种含义都等价于「本腿没有 placement 要提交」:①本部署不是 device 车道(绝大多数);
134
+ * ②本腿不是 fresh 根提交(resume 家族根本不经 `prepareSpec`)。在场 ⇒ 每个 `runStore.createRun(`
135
+ * 成功点**必须**把它交给 `commitPlacement`(唯一写入属主;类级机器门钉住每个调用点都接了)。
136
+ */
137
+ bindingIntent?: import("./device-placement.js").DevicePlacementIntent;
130
138
  }
131
139
  /**
132
140
  * 一条会话的**续跑重建输入**(`checkpoint_ctx` 的行体)—— 提交腿写一次,续跑腿读它重建 spec。
@@ -66,7 +66,7 @@ export async function handleCapabilities(req, res, match, ctx) {
66
66
  approvals: Boolean(deps.checkpointStore),
67
67
  sessions: Boolean(deps.sessionAudit),
68
68
  deviceExecutor: deps.deviceHub
69
- ? { enabled: true, protocolVersion: DEVICE_PROTOCOL_VERSION, maxInflightPerDevice: deps.config.remoteExec?.provider === "device" ? (deps.config.remoteExec.maxInflightPerDevice ?? 4) : 4, wsPath: DEVICE_WS_PATH }
69
+ ? { enabled: true, protocolVersion: DEVICE_PROTOCOL_VERSION, maxInflightPerDevice: deps.config.remoteExec?.provider === "device" ? (deps.config.remoteExec.maxInflightPerDevice ?? 4) : 4, wsPath: DEVICE_WS_PATH, management: Boolean(deps.deviceStore && deps.deviceEnrollment) }
70
70
  : false,
71
71
  sessionEvents: Boolean(deps.sessionWatch && deps.sessionStorage?.getLeafId && deps.sessionStorage?.ownerOf),
72
72
  fleet: deps.fleetBus
@@ -33,8 +33,9 @@
33
33
  * `ssoVerifiedScope`(registry-JWT 的 `scope` claim,security.ts:同为签名真源、非自报头)。两者齐备
34
34
  * 才构成一个 device 域;缺 tenant claim 的凭据(纯 principal 头 / 静态 token 直连)**判不出域** ⇒
35
35
  * 非 operator 一律响亮 403(fail-closed + loud,[ref]:静默空列表是假健康)——基线稿对 enrollment 的
36
- * 同向裁定是「缺 tenant/global scope 明确拒注册」。将来的 enrollment/管理端点(车C 余件)**必须**从
37
- * 这同一个函数取域,不得手抄第二份派生。
36
+ * 同向裁定是「缺 tenant/global scope 明确拒注册」。enrollment/管理端点与**首绑写协议**都从
37
+ * 这同一个函数取域(S-470 起它住在 `http/device-placement.ts` —— 提交腿也要用,放在路由域里是层序倒挂),
38
+ * 不得手抄第二份派生。
38
39
  *
39
40
  * ── 反枚举 404 的单一铸造点 ──────────────────────────────────────────────────────────────────────
40
41
  * 「绑定不在 / 不是你的 / 目标设备不在 / 不是该域的 / 已吊销」五臂共用 {@link sendDeviceNotFound} ——
@@ -54,18 +55,27 @@
54
55
  */
55
56
  import type { IncomingMessage, ServerResponse } from "node:http";
56
57
  import type { RouteCtx, RouteMatch, RouteIdsOf } 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
58
  export declare function handleDevices(req: IncomingMessage, res: ServerResponse, match: RouteMatch<DevicesRouteId>, ctx: RouteCtx): Promise<void>;
64
59
  export declare const DEVICES_ROUTES: readonly [{
65
60
  readonly id: "devices-list";
66
61
  readonly path: "/v1/devices";
67
62
  readonly credentialGated: readonly ["GET"];
68
63
  readonly methods: readonly ["GET"];
64
+ }, {
65
+ readonly id: "device-enroll-tokens";
66
+ readonly path: "/v1/devices/enroll-tokens";
67
+ readonly credentialGated: readonly ["POST"];
68
+ readonly methods: readonly ["POST"];
69
+ }, {
70
+ readonly id: "device-enroll";
71
+ readonly path: "/v1/devices/enroll";
72
+ readonly credentialGated: readonly ["POST"];
73
+ readonly methods: readonly ["POST"];
74
+ }, {
75
+ readonly id: "device-revoke";
76
+ readonly pattern: RegExp;
77
+ readonly label: "/v1/devices/:deviceId/revoke";
78
+ readonly methods: readonly ["POST"];
69
79
  }, {
70
80
  readonly id: "device-session-rebind";
71
81
  readonly pattern: RegExp;
@@ -1,8 +1,10 @@
1
1
  import { sendError, sendJson } from "../send.js";
2
2
  import { explicitOperatorOk, gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
3
3
  import { assertNever } from "../route-ctx.js";
4
- import { ssoVerifiedScope } from "../../security.js";
5
- import { assertDeviceIdShape, assertRootSessionIdShape, sameDeviceOwner, } from "../../device-store.js";
4
+ import { DEVICE_PLATFORM_OS, DEVICE_PUBKEY_MAX_BYTES, DEVICE_TEXT_LIMITS, assertDeviceIdShape, assertDevicePubkeyShape, assertRootSessionIdShape, sameDeviceOwner, } from "../../device-store.js";
5
+ import { DEFAULT_ENROLL_TOKEN_TTL_SEC } from "../../device-enrollment.js";
6
+ import { deviceOwnerDomainOf } from "../device-placement.js";
7
+ import { echoKeyName } from "../../request-key-closure.js";
6
8
  const DEVICE_NOT_FOUND_MESSAGE = "device not found";
7
9
  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";
8
10
  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";
@@ -11,15 +13,6 @@ void _deviceRouteCodesPinned;
11
13
  function sendDeviceNotFound(res) {
12
14
  sendError(res, 404, "not_found.device", DEVICE_NOT_FOUND_MESSAGE);
13
15
  }
14
- export function deviceOwnerDomainOf(req, config) {
15
- const subject = gatedPrincipal(req, config);
16
- if (subject === undefined)
17
- return undefined;
18
- const tenant = ssoVerifiedScope(req);
19
- if (tenant === undefined)
20
- return undefined;
21
- return { tenant, subject };
22
- }
23
16
  function wellFormedKey(assert, v) {
24
17
  try {
25
18
  assert(v);
@@ -40,11 +33,95 @@ function deviceListRow(row, online) {
40
33
  function boundDeviceOf(row) {
41
34
  return { deviceId: row.deviceId, rev: row.rev, boundAt: new Date(row.boundAtMs).toISOString() };
42
35
  }
36
+ function sendDeviceRejection(res, rej) {
37
+ if (rej.retryAfterSec !== undefined)
38
+ res.setHeader("retry-after", String(rej.retryAfterSec));
39
+ sendError(res, rej.status, rej.code, rej.message);
40
+ }
41
+ function bodyKeyIssue(body, accepted) {
42
+ const extra = Object.keys(body).filter((k) => !accepted.includes(k)).map((k) => echoKeyName("", k)).sort();
43
+ if (extra.length === 0)
44
+ return null;
45
+ return `unknown key(s) in body: ${extra.join(", ")} — this endpoint accepts ${accepted.length === 0 ? "no body keys" : accepted.slice().sort().join(", ")} (a silently dropped key is a declaration that never existed)`;
46
+ }
47
+ async function readClosedBody(req, res, readJson, accepted) {
48
+ let body;
49
+ try {
50
+ body = await readJson(req);
51
+ }
52
+ catch {
53
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
54
+ return null;
55
+ }
56
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
57
+ sendError(res, 400, "request.body_shape", "body must be a JSON object");
58
+ return null;
59
+ }
60
+ const issue = bodyKeyIssue(body, accepted);
61
+ if (issue !== null) {
62
+ sendError(res, 400, "request.body_shape", issue);
63
+ return null;
64
+ }
65
+ return body;
66
+ }
67
+ const ENROLL_BODY_KEYS = ["token", "pubkey", "platformOs", "platformArch", "displayName", "workspaceRoot"];
68
+ function parseEnrollBody(res, body) {
69
+ const str = (k, required) => {
70
+ const v = body[k];
71
+ if (v === undefined)
72
+ return required ? (sendError(res, 400, "request.body_shape", `${k} is required`), null) : "";
73
+ if (typeof v !== "string")
74
+ return sendError(res, 400, "request.field_invalid", `${k} must be a string`), null;
75
+ return v;
76
+ };
77
+ const token = str("token", true);
78
+ if (token === null)
79
+ return null;
80
+ const pubkey = str("pubkey", true);
81
+ if (pubkey === null)
82
+ return null;
83
+ const platformArch = str("platformArch", false);
84
+ if (platformArch === null)
85
+ return null;
86
+ const displayName = str("displayName", false);
87
+ if (displayName === null)
88
+ return null;
89
+ const workspaceRoot = str("workspaceRoot", true);
90
+ if (workspaceRoot === null)
91
+ return null;
92
+ const os = DEVICE_PLATFORM_OS.find((o) => o === body.platformOs);
93
+ if (os === undefined) {
94
+ sendError(res, 400, "request.field_invalid", `platformOs must be one of ${DEVICE_PLATFORM_OS.join(" | ")} (v1 closed set — win32 is not supported yet, and is never silently accepted as a third value)`);
95
+ return null;
96
+ }
97
+ if (token.length === 0 || token.length > 256) {
98
+ sendError(res, 400, "request.field_invalid", "token must be a non-empty string of at most 256 characters");
99
+ return null;
100
+ }
101
+ if (!wellFormedKey(assertDevicePubkeyShape, pubkey)) {
102
+ sendError(res, 400, "request.field_invalid", `pubkey is not a valid device public key (byte-exact key: non-empty, no control characters or surrounding whitespace, at most ${DEVICE_PUBKEY_MAX_BYTES} bytes)`);
103
+ return null;
104
+ }
105
+ for (const [k, v, max] of [["displayName", displayName, DEVICE_TEXT_LIMITS.displayName], ["platformArch", platformArch, DEVICE_TEXT_LIMITS.platformArch], ["workspaceRoot", workspaceRoot, DEVICE_TEXT_LIMITS.workspaceRoot]]) {
106
+ if (Buffer.byteLength(v, "utf8") > max) {
107
+ sendError(res, 400, "request.field_invalid", `${k} exceeds the ${max}-byte column (a silently truncated value is a different value — refusing at the door)`);
108
+ return null;
109
+ }
110
+ }
111
+ if (workspaceRoot.length === 0) {
112
+ sendError(res, 400, "request.field_invalid", "workspaceRoot is required and must be non-empty (it is the device-side root every instruction resolves against)");
113
+ return null;
114
+ }
115
+ return { token: token, pubkey: pubkey, platformOs: os, platformArch: platformArch, displayName: displayName, workspaceRoot: workspaceRoot };
116
+ }
43
117
  export async function handleDevices(req, res, match, ctx) {
44
118
  switch (match.decl.id) {
45
119
  case "devices-list":
46
120
  case "device-session":
47
121
  case "device-session-rebind":
122
+ case "device-enroll-tokens":
123
+ case "device-enroll":
124
+ case "device-revoke":
48
125
  break;
49
126
  default:
50
127
  return assertNever(match.decl.id);
@@ -58,7 +135,8 @@ export async function handleDevices(req, res, match, ctx) {
58
135
  }
59
136
  const store = deps.deviceStore;
60
137
  const hub = deps.deviceHub;
61
- if (!store || !hub) {
138
+ const enrollment = deps.deviceEnrollment;
139
+ if (!store || !hub || !enrollment) {
62
140
  sendError(res, 501, "capability.device_lane_required", DEVICE_LANE_REQUIRED_MESSAGE);
63
141
  return;
64
142
  }
@@ -74,6 +152,88 @@ export async function handleDevices(req, res, match, ctx) {
74
152
  sendJson(res, 200, { devices: rows.filter((r) => r.status !== "revoked").map((r) => deviceListRow(r, online)) });
75
153
  return;
76
154
  }
155
+ if (match.decl.id === "device-enroll-tokens") {
156
+ if (domain === undefined) {
157
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
158
+ return;
159
+ }
160
+ if ((await readClosedBody(req, res, readJson, [])) === null)
161
+ return;
162
+ const ttlSeconds = (deps.config.remoteExec?.provider === "device" ? deps.config.remoteExec.enrollTokenTtlSec : undefined) ?? DEFAULT_ENROLL_TOKEN_TTL_SEC;
163
+ const issued = await enrollment.issueEnrollToken({ target: domain, createdBy: domain.subject, ttlSeconds });
164
+ if (!issued.ok) {
165
+ sendDeviceRejection(res, issued.reject);
166
+ return;
167
+ }
168
+ sendJson(res, 200, { token: issued.value.token, expiresAt: new Date(issued.value.row.expiresAtMs).toISOString(), ttlSec: ttlSeconds });
169
+ return;
170
+ }
171
+ if (match.decl.id === "device-enroll") {
172
+ if (domain === undefined) {
173
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
174
+ return;
175
+ }
176
+ const raw = await readClosedBody(req, res, readJson, ENROLL_BODY_KEYS);
177
+ if (raw === null)
178
+ return;
179
+ const body = parseEnrollBody(res, raw);
180
+ if (body === null)
181
+ return;
182
+ const outcome = await enrollment.enroll({
183
+ token: body.token,
184
+ requester: domain,
185
+ device: { displayName: body.displayName, platformOs: body.platformOs, platformArch: body.platformArch, pubkey: body.pubkey, workspaceRoot: body.workspaceRoot },
186
+ });
187
+ if (!outcome.ok) {
188
+ sendDeviceRejection(res, outcome.reject);
189
+ return;
190
+ }
191
+ sendJson(res, 200, { deviceId: outcome.value.device.deviceId });
192
+ return;
193
+ }
194
+ if (match.decl.id === "device-revoke") {
195
+ const decodedId = safeDecode(match.params.deviceId);
196
+ if (decodedId === null) {
197
+ sendError(res, 400, "request.path_malformed", "device id segment is not valid percent-encoding");
198
+ return;
199
+ }
200
+ if (!wellFormedKey(assertDeviceIdShape, decodedId)) {
201
+ sendError(res, 400, "request.id_invalid", "invalid device id segment (device keys are byte-exact: non-empty, no control characters or surrounding whitespace, at most 64 bytes)");
202
+ return;
203
+ }
204
+ if (!operator && domain === undefined) {
205
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
206
+ return;
207
+ }
208
+ const body = await readClosedBody(req, res, readJson, ["reason"]);
209
+ if (body === null)
210
+ return;
211
+ const reason = body.reason;
212
+ if (reason !== undefined && (typeof reason !== "string" || Buffer.byteLength(reason, "utf8") > DEVICE_TEXT_LIMITS.revokeReason)) {
213
+ sendError(res, 400, "request.field_invalid", `reason must be a string of at most ${DEVICE_TEXT_LIMITS.revokeReason} bytes (a silently truncated audit reason is a different reason)`);
214
+ return;
215
+ }
216
+ let owner;
217
+ if (operator) {
218
+ const row = await store.getDeviceUnscoped(decodedId);
219
+ if (!row) {
220
+ sendDeviceNotFound(res);
221
+ return;
222
+ }
223
+ owner = row.owner;
224
+ }
225
+ else {
226
+ owner = domain;
227
+ }
228
+ const revoked = await enrollment.revoke({ deviceId: decodedId, requester: owner, revokedBy: principal ?? owner.subject, reason: reason === undefined ? null : reason });
229
+ if (!revoked.ok) {
230
+ sendDeviceRejection(res, revoked.reject);
231
+ return;
232
+ }
233
+ await hub.fenceDevice(decodedId).catch((err) => deps.logger?.warn("device_revoke_fence_failed", { deviceId: decodedId, err: err instanceof Error ? err.message : String(err) }));
234
+ sendJson(res, 200, { deviceId: revoked.value.deviceId, status: revoked.value.status, ...(revoked.value.revokedAtMs !== null ? { revokedAt: new Date(revoked.value.revokedAtMs).toISOString() } : {}) });
235
+ return;
236
+ }
77
237
  const decoded = safeDecode(match.params.rootSessionId);
78
238
  if (decoded === null) {
79
239
  sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
@@ -164,6 +324,9 @@ export async function handleDevices(req, res, match, ctx) {
164
324
  }
165
325
  export const DEVICES_ROUTES = [
166
326
  { id: "devices-list", path: "/v1/devices", credentialGated: ["GET"], methods: ["GET"] },
327
+ { id: "device-enroll-tokens", path: "/v1/devices/enroll-tokens", credentialGated: ["POST"], methods: ["POST"] },
328
+ { id: "device-enroll", path: "/v1/devices/enroll", credentialGated: ["POST"], methods: ["POST"] },
329
+ { id: "device-revoke", pattern: /^\/v1\/devices\/(?<deviceId>[^/]+)\/revoke$/, label: "/v1/devices/:deviceId/revoke", methods: ["POST"] },
167
330
  { id: "device-session-rebind", pattern: /^\/v1\/devices\/sessions\/(?<rootSessionId>[^/]+)\/rebind$/, label: "/v1/devices/sessions/:id/rebind", methods: ["POST"] },
168
331
  { id: "device-session", pattern: /^\/v1\/devices\/sessions\/(?<rootSessionId>[^/]+)$/, label: "/v1/devices/sessions/:id", credentialGated: ["GET"], methods: ["GET"] },
169
332
  { prefix: "/v1/devices/", credentialGated: ["POST"] },
@@ -21,6 +21,7 @@ import { collectSsePreamble } from "../sse-log.js";
21
21
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
22
22
  import { createApprovalCardEmitter, resolveApprovalLeg, resolveStreamApprovalGate } from "../../tool-approval.js";
23
23
  import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
24
+ import { commitPlacement, devicePlacementErrorBody } from "../device-placement.js";
24
25
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
25
26
  import { failOpenTagForDroppedFrame, recordFailOpen } from "../../observability/fail-open.js";
26
27
  import { assertNever } from "../route-ctx.js";
@@ -226,6 +227,12 @@ export async function handleTasks(req, res, match, ctx) {
226
227
  sseData(res, { type: "done", result: toDoneFrameResult(conflict) });
227
228
  return { status: 409, body: conflict };
228
229
  }
230
+ const placementReject = await commitPlacement(deps, deps.runStore, tid, prepared.bindingIntent);
231
+ if (placementReject) {
232
+ const body = devicePlacementErrorBody(placementReject);
233
+ sseData(res, { type: "done", result: { status: "failed", errorCode: body.errorCode, errorMessage: body.error } });
234
+ return { status: placementReject.status, body };
235
+ }
229
236
  durableTaskId = tid;
230
237
  }
231
238
  if (prepared.spec.sessionId)
@@ -771,6 +778,9 @@ export async function handleTasks(req, res, match, ctx) {
771
778
  recordResourceWindow(tid, prepared.spec.resourceSuspend);
772
779
  if (!created.ok)
773
780
  return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, prepared.spec.sessionId, created.activeTaskId) };
781
+ const placementReject = await commitPlacement(deps, deps.runStore, tid, prepared.bindingIntent);
782
+ if (placementReject)
783
+ return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
774
784
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
775
785
  durableTaskId = tid;
776
786
  }
@@ -304,6 +304,19 @@ export interface ServiceSeamDeps {
304
304
  * v2 换绑稿 §7)。
305
305
  */
306
306
  deviceStore?: import("../device-store.js").DeviceStore;
307
+ /**
308
+ * S-470(车C 余件):device lane 的**准入链**(`createDeviceEnrollment`)—— 管理面三个写动词
309
+ * (签发 / 注册 / 吊销)与首绑写协议(`commitPlacement`)的**唯一**判据属主。
310
+ *
311
+ * 🔴 为什么路由拿的是它而不是 {@link deviceStore}:enrollment 那一层持着两件路由造不出来的东西 ——
312
+ * ①跨副本限速座(`DeviceEnrollRateLimiter`,§7-T5 ⑤:端点车**必须**传一个,而它由 boot 从既有
313
+ * `RateGate` 适配);②反枚举拒绝的**单一铸造点**(四种 enrollment 失败理由在 wire 上逐字节同形)。
314
+ * 路由若绕过它直接打店,那两件就会在路由里被手抄第二份 —— 而手抄的那份迟早漏一臂。
315
+ *
316
+ * 与 {@link deviceStore} / {@link deviceHub} **同源装配**(`boot/device-lane.ts` 一处建齐三件),
317
+ * 所以三者的在场性是同一个事实;族内 501 谓词因此可以是「三件齐」这一句话。
318
+ */
319
+ deviceEnrollment?: import("../device-enrollment.js").DeviceEnrollment;
307
320
  /** Audit回溯: current context (+ summary) for a session, plus its owner. Enables GET /v1/sessions/:id. */
308
321
  sessionAudit?: (sessionId: string) => Promise<({
309
322
  owner: string | null;
@@ -362,6 +362,21 @@ export interface TaskRequestBody {
362
362
  * (a caller can't point a shared/cloud worker at an arbitrary host path). Validated absolute (prepareSpec 400s a
363
363
  * relative cwd — it would silently resolve against the SERVICE process cwd). */
364
364
  cwd?: string;
365
+ /**
366
+ * S-470(device lane 首绑写协议,design `device-executor-lane-v2` §4.3.2)—— 这条**根会话**要执行在
367
+ * 哪台已注册的员工设备上(`POST /v1/devices/enroll` 返回的 `dev_<ulid>`)。
368
+ *
369
+ * 与 {@link cwd} **同级同信任姿势**:调用方供给、server 闸裁 —— 它不是身份,身份恒取验证过的
370
+ * 复合身份链;它只是「在我自己名下的设备里挑一台」。
371
+ * · 仅 `REMOTE_EXEC=device` 的部署接受;其它车道收到它 = **400 响亮拒**(不静默忽略 —— 调用方会
372
+ * 以为自己指定了执行设备,而那条声明从未存在);
373
+ * · 仅**根任务**提交携带(子代无条件继承根绑定,core 为 subagent/cascade/verify 另铸 sessionId);
374
+ * · 首绑 = INSERT-if-absent CAS(并发单赢家);后续根提交:缺席 = 沿用绑定 / 携且同 = 幂等 /
375
+ * 携且异 = 403 `device.identity_mismatch`(**v1 无隐式重绑**,换设备走
376
+ * `POST /v1/devices/sessions/:rootSessionId/rebind`);
377
+ * · resume 家族(`/decide` / `/answer` / `/plan_review` / `/wake`)**不收**这个键:车道随树固定。
378
+ */
379
+ deviceId?: string;
365
380
  /** [ref] (CC `--add-dir`, core `TaskSpec.additionalDirectories`): extra host dirs the FILE tools may reach
366
381
  * beyond the containment root. 🔒 Gated exactly like `cwd`/`shellEnv` — honored ONLY on the single-user host
367
382
  * lane (`task-cwd.ts` `cwdHonored`); off that lane `boot/resolve-spec.ts` drops them with a loud
@@ -0,0 +1,15 @@
1
+ /** 机读码(拼法随 `config.<键名蛇形>_*` 既有族:`config.model_provider_invalid` / `config.retention_policy_invalid`)。 */
2
+ export declare const GATEWAY_URL_USERINFO_CODE = "config.gateway_url_userinfo";
3
+ /** 这个 URL 带 userinfo(用户名或密码任一非空)吗?解析不动 ⇒ `false`(「不是 URL」由别的门管,本条只判凭据位)。 */
4
+ export declare function urlCarriesUserinfo(raw: string): boolean;
5
+ /**
6
+ * 模型路由 base URL 的拒因(`undefined` = 这一位合法)。
7
+ *
8
+ * 拒因四件齐:**码** + **哪个 env 键** + **洗过的 URL**(host/port/path 逐字,userinfo 与凭据参数成记号 ——
9
+ * 运维要靠 host 认出是哪一条路由,而密码一个字节都不许进启动错误)+ **出路一句**(点名真实旋钮)。
10
+ *
11
+ * @param envKey 出问题的那根 env 旋钮的名字(CSV 键上还带第几项)。
12
+ * @param credentialKnob 这条路由把凭据放哪儿 —— 出路一句点名的旋钮名。
13
+ */
14
+ export declare function modelRouteUrlRefusal(envKey: string, raw: string, credentialKnob: string): string | undefined;
15
+ //# sourceMappingURL=model-route-url.d.ts.map
@@ -0,0 +1,19 @@
1
+ import { buildDisplaySafeUrl } from "./trace/redact.js";
2
+ export const GATEWAY_URL_USERINFO_CODE = "config.gateway_url_userinfo";
3
+ export function urlCarriesUserinfo(raw) {
4
+ if (raw === "" || !URL.canParse(raw))
5
+ return false;
6
+ const u = new URL(raw);
7
+ return u.username !== "" || u.password !== "";
8
+ }
9
+ export function modelRouteUrlRefusal(envKey, raw, credentialKnob) {
10
+ if (!urlCarriesUserinfo(raw))
11
+ return undefined;
12
+ return (`env ${envKey} carries credentials in the URL userinfo (${buildDisplaySafeUrl(raw)}) [${GATEWAY_URL_USERINFO_CODE}] — ` +
13
+ "WHATWG forbids constructing a fetch Request from a URL with credentials, so EVERY model call on this route " +
14
+ "fails deterministically at request-construction time (it used to look like a slow network flake: the error was " +
15
+ "retried for ~3 minutes before surfacing). " +
16
+ `Put the credential in ${credentialKnob} (or the matching header knob) instead of the URL, and keep the URL to ` +
17
+ "scheme/host/port/path.");
18
+ }
19
+ //# sourceMappingURL=model-route-url.js.map
@@ -305,6 +305,15 @@ export interface FailOpenRecorderDeps {
305
305
  seedWarned?: ReadonlySet<FailOpenTag>;
306
306
  seedBacklog?: ReadonlyMap<FailOpenTag, number>;
307
307
  seedLogged?: ReadonlySet<FailOpenTag>;
308
+ /**
309
+ * `detail` 落探针文件前的脱敏器(装配层注入 `trace/redact.ts` 的 `redactSecrets`)。
310
+ *
311
+ * 🔴 **缺席 ⇒ detail 原文不落盘,只落 tag + 一个「为何扣下」的标记**(fail-closed 且响亮);脱敏器抛 ⇒ 同。理由:探针行是本模块**唯一**
312
+ * 把调用方文本写到盘上的地方,而三十多个调用方里不少传的是异常文本 —— 异常文本里带网关 URL、URL 的 query 里带
313
+ * `api_key=` 都是常态。「不放密钥」此前只是 {@link recordFailOpen} 注释里的一句自觉,现在是这一个铸点上的结构。
314
+ * 用注入而不是直接 import:脱敏器自己 import 了本模块(它有一条 fail-open 留痕臂),直接 import 会成环。
315
+ */
316
+ redactDetail?: (detail: string) => string;
308
317
  /** 测试注入:替掉 stderr 契约行的落点。缺省直写 `process.stderr`。 */
309
318
  writeStderr?: (line: string) => void;
310
319
  /** 测试注入:替掉探针文件追加。缺省 `appendFileSync`(同步=进程猝死也不丢已记的行)。 */
@@ -320,7 +329,8 @@ export declare function createFailOpenRecorder(deps?: FailOpenRecorderDeps): Fai
320
329
  * - `seedLogged` —— 已真发出去的结构化 warn 不重发;没发过的由新 logger 补一条。
321
330
  */
322
331
  export declare function installFailOpenRecorder(deps: Omit<FailOpenRecorderDeps, "seedCounts" | "seedWarned" | "seedBacklog" | "seedLogged">): FailOpenRecorder;
323
- /** 兜底臂的调用口。`tag` 必须已登记(闭集类型),`detail` 是可选的一行现场(路径/原因,不放密钥)。 */
332
+ /** 兜底臂的调用口。`tag` 必须已登记(闭集类型),`detail` 是可选的一行现场(路径/原因)。落盘前由记录器统一脱敏
333
+ * ({@link FailOpenRecorderDeps.redactDetail});调用方仍不该**刻意**往里放密钥,但不再靠这句自觉兜底。 */
324
334
  export declare function recordFailOpen(tag: FailOpenTag, detail?: string): void;
325
335
  /** 本进程各 tag 累计次数——`/metrics` 之外的进程内读数(测试与自检面用)。 */
326
336
  export declare function failOpenCounts(): ReadonlyMap<FailOpenTag, number>;
@@ -283,6 +283,7 @@ export function createFailOpenRecorder(deps = {}) {
283
283
  }
284
284
  return {
285
285
  record: (tag, detail) => {
286
+ const safeDetail = renderDetail(detail, deps.redactDetail);
286
287
  const entry = FAIL_OPEN_TAGS[tag];
287
288
  counts.set(tag, (counts.get(tag) ?? 0) + 1);
288
289
  if (deps.metrics === undefined) {
@@ -298,14 +299,14 @@ export function createFailOpenRecorder(deps = {}) {
298
299
  }
299
300
  if (probePath !== undefined && probePath !== "") {
300
301
  try {
301
- appendProbe(probePath, `fail-open ${tag}${detail !== undefined && detail !== "" ? ` ${detail}` : ""}\n`);
302
+ appendProbe(probePath, `fail-open ${tag}${safeDetail === undefined ? "" : ` ${safeDetail}`}\n`);
302
303
  }
303
304
  catch {
304
305
  }
305
306
  }
306
307
  if (deps.logger !== undefined && !logged.has(tag)) {
307
308
  try {
308
- deps.logger.warn("fail_open", { tag, cls: entry.cls, note: entry.note, ...(detail !== undefined ? { detail } : {}) });
309
+ deps.logger.warn("fail_open", { tag, cls: entry.cls, note: entry.note, ...(safeDetail !== undefined ? { detail: safeDetail } : {}) });
309
310
  logged.add(tag);
310
311
  }
311
312
  catch {
@@ -327,6 +328,21 @@ export function createFailOpenRecorder(deps = {}) {
327
328
  };
328
329
  }
329
330
  let current = createFailOpenRecorder();
331
+ function renderDetail(detail, redactDetail) {
332
+ if (detail === undefined || detail === "")
333
+ return undefined;
334
+ if (redactDetail === undefined)
335
+ return "[detail withheld: no redactor installed]";
336
+ let safe;
337
+ try {
338
+ safe = redactDetail(detail);
339
+ }
340
+ catch (err) {
341
+ return `[detail withheld: redactor threw ${err instanceof Error ? err.name : typeof err}]`;
342
+ }
343
+ const oneLine = safe.replace(/[\r\n]+/g, " ");
344
+ return oneLine === "" ? undefined : oneLine;
345
+ }
330
346
  export function installFailOpenRecorder(deps) {
331
347
  current = createFailOpenRecorder({
332
348
  ...deps,
@@ -20,7 +20,7 @@ export function keyClosureClause(names, label) {
20
20
  const TASK_BODY_DECLARED_KEYS = [
21
21
  "a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
22
22
  "appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
23
- "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "enableFork", "excludeTools",
23
+ "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork", "excludeTools",
24
24
  "finalVerification", "forwardSubagentEvents", "images", "interactiveTools", "jobId", "limits", "maxCostUsd",
25
25
  "maxTokens", "mcpServers", "memory", "memoryCapture", "memoryWrite", "model", "objective", "oneShot", "outputRetries",
26
26
  "outputSchema", "permissionMode", "projectId", "promptProfile", "reasoningEffort", "requireExistingSession",
package/dist/run-local.js CHANGED
@@ -43,6 +43,7 @@ import { createKeyResolver } from "./key-resolver.js";
43
43
  import { createLogger } from "./observability/logger.js";
44
44
  import { createMetrics } from "./observability/metrics.js";
45
45
  import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
46
+ import { buildDisplaySafeUrl } from "./trace/redact.js";
46
47
  export function parseArgs(argv) {
47
48
  let objective;
48
49
  let root;
@@ -299,7 +300,7 @@ export async function runLocal(argv, deps = {}) {
299
300
  ...(config.memorySync.maxPushEntries !== undefined ? { maxPushEntries: config.memorySync.maxPushEntries } : {}),
300
301
  ...(config.memorySync.maxPullEntries !== undefined ? { maxPullEntries: config.memorySync.maxPullEntries } : {}),
301
302
  });
302
- logger.info("memory_sync_enabled", { url: config.memorySync.url, scope: config.memorySync.scope, cursorPath: memorySync.cursorPath });
303
+ logger.info("memory_sync_enabled", { url: buildDisplaySafeUrl(config.memorySync.url), scope: config.memorySync.scope, cursorPath: memorySync.cursorPath });
303
304
  memorySync.trigger("boot");
304
305
  }
305
306
  else if (config.memorySync) {
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import _sodium from "libsodium-wrappers";
5
+ import { buildDisplaySafeUrl } from "./trace/redact.js";
5
6
  export const SEALED_KEY_ALG = "libsodium-sealedbox-x25519";
6
7
  const SEAL_OVERHEAD_BYTES = 48;
7
8
  const BASE64_STRICT = /^[A-Za-z0-9+/]+={0,2}$/;
@@ -104,7 +105,7 @@ export async function reportExecutionPublicKey(opts) {
104
105
  alg: SEALED_KEY_ALG,
105
106
  createdAt: key.createdAt,
106
107
  why,
107
- register: `POST ${baseUrl ?? "<registry>"}/api/config/execution-keys (admin token) body {"publicKey":"${key.publicKey}","publicKeyId":"${key.publicKeyId}"}`,
108
+ register: `POST ${baseUrl === undefined ? "<registry>" : buildDisplaySafeUrl(baseUrl)}/api/config/execution-keys (admin token) body {"publicKey":"${key.publicKey}","publicKeyId":"${key.publicKeyId}"}`,
108
109
  note: "until an admin registers this public key, web-pasted (sealed) api keys cannot target this host",
109
110
  });
110
111
  };
@@ -185,6 +185,12 @@ export declare class HttpError extends Error {
185
185
  extra?: Record<string, unknown>;
186
186
  });
187
187
  }
188
+ /**
189
+ * 带机读码的 {@link HttpError} 的**构造点**。出门时这只码落在 wire 错误体的 `errorCode` 键上(`http/send.ts`),
190
+ * 从不以裸 `code` 键出现 —— 3.0.0 起 `errorCode` 是错误体唯一的机器判别键。`http/` 树里的模块经它抛带码错误,
191
+ * 不手写 `{ code }` 选项字面量(那一层有一道按行扫裸 `code:` 键的门,手写字面量与「真发了 legacy 键」同形)。
192
+ */
193
+ export declare function createCodedHttpError(status: number, errorCode: string, message: string, extra?: Record<string, unknown>): HttpError;
188
194
  /**
189
195
  * Authorization context resolved per request, BEFORE building the TaskSpec. The service owns this —
190
196
  * identity and tenant scope come from the authenticated channel, **never from the request body**.
package/dist/security.js CHANGED
@@ -39,6 +39,9 @@ export class HttpError extends Error {
39
39
  this.extra = opts.extra;
40
40
  }
41
41
  }
42
+ export function createCodedHttpError(status, errorCode, message, extra) {
43
+ return new HttpError(status, message, extra === undefined ? { code: errorCode } : { code: errorCode, extra });
44
+ }
42
45
  export function createAuthorizer(config, sessionStore) {
43
46
  return async ({ req, body }) => {
44
47
  const principal = verifiedPrincipal(req, config);
@@ -1,5 +1,6 @@
1
1
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
2
2
  import { recordFailOpen } from "./observability/fail-open.js";
3
+ import { redactSecrets } from "./trace/redact.js";
3
4
  export const TITLE_MAX_CHARS = 80;
4
5
  const OBJECTIVE_SNIPPET_CHARS = 600;
5
6
  const LLM_TIMEOUT_MS = 15_000;
@@ -23,8 +24,9 @@ export function buildTitlePrompt(objective) {
23
24
  export function createSessionTitler(deps) {
24
25
  const seen = new Set();
25
26
  const logFailure = (sessionId, error) => {
26
- deps.logger?.debug?.("session_title_failed", { sessionId, error: error.slice(0, 200) });
27
- recordFailOpen("server.session-titler.gateway-unreachable", error.slice(0, FAIL_CAUSE_KEY_CHARS));
27
+ const safe = redactSecrets(error);
28
+ deps.logger?.debug?.("session_title_failed", { sessionId, error: safe.slice(0, 200) });
29
+ recordFailOpen("server.session-titler.gateway-unreachable", safe.slice(0, FAIL_CAUSE_KEY_CHARS));
28
30
  };
29
31
  const probeOrContinue = async (sessionId, continueWord) => deps
30
32
  .probeTitle(sessionId)