@sema-agent/server 7.87.2 → 7.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-bridge.js +5 -3
- package/dist/boot/device-lane.d.ts +12 -19
- package/dist/boot/device-lane.js +3 -13
- package/dist/boot/stage-05-execution-env.js +1 -2
- package/dist/boot/stage-07-capability-layer.js +2 -1
- package/dist/boot/stage-09-leader.js +2 -1
- package/dist/boot/stage-10-http-server.js +4 -2
- package/dist/boot/stores.js +2 -1
- package/dist/brain.js +3 -2
- package/dist/config-catalog.js +4 -3
- package/dist/config-center/apply-effective.js +3 -2
- package/dist/config-center/http-client.d.ts +11 -2
- package/dist/config-center/http-client.js +1 -1
- package/dist/config-center/read-warnings.d.ts +1 -1
- package/dist/config-center/read-warnings.js +5 -3
- package/dist/config-center/types.d.ts +3 -1
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +12 -0
- package/dist/config-provider.d.ts +9 -1
- package/dist/config-provider.js +3 -1
- package/dist/config-types.d.ts +7 -0
- package/dist/config.js +28 -3
- package/dist/device-enrollment.d.ts +25 -4
- package/dist/device-enrollment.js +20 -2
- package/dist/device-protocol.d.ts +15 -0
- package/dist/device-protocol.js +2 -0
- package/dist/device-ws-hub.d.ts +11 -0
- package/dist/device-ws-hub.js +6 -0
- package/dist/fleet-client.js +2 -1
- package/dist/http/admission.js +10 -1
- package/dist/http/device-placement.d.ts +177 -0
- package/dist/http/device-placement.js +148 -0
- package/dist/http/durable-run-leg.js +4 -0
- package/dist/http/resume-legs.js +4 -0
- package/dist/http/route-ctx.d.ts +8 -0
- package/dist/http/routes/capabilities.js +1 -1
- package/dist/http/routes/devices.d.ts +18 -8
- package/dist/http/routes/devices.js +175 -12
- package/dist/http/routes/tasks.js +10 -0
- package/dist/http/server.d.ts +13 -0
- package/dist/http/wire-types.d.ts +15 -0
- package/dist/model-route-url.d.ts +15 -0
- package/dist/model-route-url.js +19 -0
- package/dist/request-key-closure.js +1 -1
- package/dist/run-local.js +2 -1
- package/dist/sealed-key.js +2 -1
- package/dist/security.d.ts +6 -0
- package/dist/security.js +3 -0
- package/dist/trace/redact.d.ts +20 -0
- package/dist/trace/redact.js +117 -26
- package/package.json +7 -3
|
@@ -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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/dist/http/server.d.ts
CHANGED
|
@@ -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
|
|
@@ -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) {
|
package/dist/sealed-key.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/security.d.ts
CHANGED
|
@@ -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);
|
package/dist/trace/redact.d.ts
CHANGED
|
@@ -136,4 +136,24 @@ export declare function redactDeep(value: unknown): unknown;
|
|
|
136
136
|
* owner-scoped, so this preview is only ever shown to the ask's own principal (or an operator inspecting it).
|
|
137
137
|
*/
|
|
138
138
|
export declare function redactedPreview(value: unknown, cap?: number): string | null;
|
|
139
|
+
/**
|
|
140
|
+
* 🔴 **配置型 URL 上日志 / 诊断面的唯一 display-safe 铸点**(S-475 件B;`build*` = 返回纯数据的一只纯函数)。
|
|
141
|
+
*
|
|
142
|
+
* 病(7.87.2 亲核):启动日志 `listening` 行把配置里的 URL 写**原值** —— `brain: brainSummary(config)` 里的
|
|
143
|
+
* `gateway: config.gatewayBaseUrl` 与同行的 `otel: config.otel.endpoint`,logger 不脱敏。部署若把凭据放在
|
|
144
|
+
* 网关 URL 的 userinfo / query 里,密码明文落 `engine-*.log`(日志的读者面比 run 错误文本更宽:它落盘、
|
|
145
|
+
* 被采集、进工单)。同类出口不止这两处,类级机器门在 `test/config-url-display-safe.test.ts`。
|
|
146
|
+
*
|
|
147
|
+
* **内部就调 {@link redactSecrets} —— 同一条 URL 律、同一张名表、同一批记号,零第二份实现**:
|
|
148
|
+
* 本函数自己只加两件事,两件都是**配置型 URL 特有**的:
|
|
149
|
+
* ① **有效性闸**:配置里的那一位应当是一个**绝对 URL**。`new URL(raw)` 解析不动 ⇒ 这一位根本不是 URL
|
|
150
|
+
* (裸 `host:port`、相对路径、手滑的散文),自由文本的 URL 律对它没有定界依据 ⇒ 返回**占位**
|
|
151
|
+
* {@link DISPLAY_SAFE_URL_TOKEN},**不回落原值**([ref]:安全轴上的兜底必须 fail-closed 或响亮)。
|
|
152
|
+
* 成文代价:`MODEL_GATEWAY_BASEURL=127.0.0.1:8000` 这种缺 scheme 的配置在日志上变成 `«redacted:url»`
|
|
153
|
+
* —— 而它在 `fetch` 那一侧本来就是必然失败的形(WHATWG 要求绝对 URL),日志上响亮一点方向一致。
|
|
154
|
+
* ② **空串 = 未设**:里面没有凭据,原样回空串(不铸占位 —— 占位会让「没配」读成「配了但看不见」)。
|
|
155
|
+
* 解析成功后**不重建 URL**(`new URL(...).toString()` 会归一化路径/端口/大小写):保形是这条律的一半,
|
|
156
|
+
* 运维要靠 host / port / path / 非凭据参数逐字排障,所以回的是**原串经脱敏器**的结果。
|
|
157
|
+
*/
|
|
158
|
+
export declare function buildDisplaySafeUrl(raw: string): string;
|
|
139
159
|
//# sourceMappingURL=redact.d.ts.map
|