@byok-sdk/server 0.2.0 → 0.3.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/README.md +16 -0
- package/dist/auth.d.ts +32 -10
- package/dist/hub.d.ts +1 -1
- package/dist/index.js +157 -67
- package/dist/index.js.map +1 -1
- package/dist/sqlite-blob-store.d.ts +1 -0
- package/dist/sqlite-support.d.ts +14 -1
- package/dist/sqlite-task-store.d.ts +1 -0
- package/dist/task-store.d.ts +2 -1
- package/dist/types.d.ts +29 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -7,4 +7,20 @@ stores over the frozen v1 protocol.
|
|
|
7
7
|
Use `@byok-sdk/cloud` plus `@byok-sdk/cloud-postgres` for the durable hosted
|
|
8
8
|
composition.
|
|
9
9
|
|
|
10
|
+
Toolset-aware dispatch names logical device-local MCP toolsets; it never sends
|
|
11
|
+
their commands or credentials:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const task = await server.dispatch({
|
|
15
|
+
deviceId,
|
|
16
|
+
instruction: 'Find five qualified prospects and draft follow-ups.',
|
|
17
|
+
runtime: 'claude',
|
|
18
|
+
policy: { mode: 'auto' },
|
|
19
|
+
requiredToolsets: ['salesko.prospecting'],
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The self-hosted coordinator rejects this call before task creation unless the
|
|
24
|
+
live device advertises `toolset-selection`.
|
|
25
|
+
|
|
10
26
|
MIT licensed. Node.js 22.19.0 or newer.
|
package/dist/auth.d.ts
CHANGED
|
@@ -16,14 +16,19 @@ export declare const ACCESS_TOKEN_TTL_SECONDS: number;
|
|
|
16
16
|
* another, and an attacker who can get a device to sign anything shaped like
|
|
17
17
|
* a nonce holds a token-renewal credential.
|
|
18
18
|
*
|
|
19
|
-
* The
|
|
19
|
+
* The literal lives in `@byok-sdk/core` (`src/pairing.ts`) and the client signs
|
|
20
|
+
* the same binding (`packages/client/src/daemon/device-keys.ts`) — it used to
|
|
21
|
+
* be three copies, each commented as byte-identical to the other two, which is
|
|
22
|
+
* an agreement that holds only until someone edits one. Re-exported here so
|
|
23
|
+
* this module's public surface is unchanged.
|
|
24
|
+
*
|
|
20
25
|
* There is deliberately no dual mode: a raw, unprefixed nonce signature is
|
|
21
26
|
* simply invalid here, with no flag, fallback, or grace window that would
|
|
22
27
|
* make the old encoding acceptable again. Because the four packages have no
|
|
23
28
|
* published compatibility contract yet, the recovery path for a device on
|
|
24
29
|
* the old encoding is a re-pair, not a server-side shim.
|
|
25
30
|
*/
|
|
26
|
-
export
|
|
31
|
+
export { NONCE_SIGNING_DOMAIN } from '@byok-sdk/core';
|
|
27
32
|
/**
|
|
28
33
|
* S1: server-local tenant identifier. A plain string alias for now — S2 moves
|
|
29
34
|
* the branded/shared form into `@byok-sdk/core`, which does not exist yet, and
|
|
@@ -133,16 +138,23 @@ export declare class NonceStore {
|
|
|
133
138
|
}
|
|
134
139
|
/**
|
|
135
140
|
* The ONLY nonce-signature check on this server (§6.2): the signed message is
|
|
136
|
-
*
|
|
137
|
-
* rather than at the call site is the point — there is one place
|
|
138
|
-
* what a device signature over a nonce means, so no route can be
|
|
139
|
-
* accepts the undomained form.
|
|
141
|
+
* core's `nonceSigningBytes` — the domain followed by the nonce. Applying the
|
|
142
|
+
* domain here rather than at the call site is the point — there is one place
|
|
143
|
+
* that decides what a device signature over a nonce means, so no route can be
|
|
144
|
+
* written that accepts the undomained form.
|
|
140
145
|
*/
|
|
141
146
|
export declare function verifyNonceSignature(devicePublicKey: string, nonce: string, signature: string): boolean;
|
|
142
147
|
export declare function extractBearerToken(header: string | undefined): string | undefined;
|
|
143
148
|
export interface AuthDeps {
|
|
144
149
|
tokenSigner: TokenSigner;
|
|
145
150
|
devices: DeviceRegistry;
|
|
151
|
+
/**
|
|
152
|
+
* The product THIS server instance serves (`createByokServer`'s
|
|
153
|
+
* `productId`). Part of authentication, not of routing: a device row paired
|
|
154
|
+
* into another product is not a principal here at all — see
|
|
155
|
+
* {@link authenticateBearer}.
|
|
156
|
+
*/
|
|
157
|
+
productId: string;
|
|
146
158
|
}
|
|
147
159
|
/**
|
|
148
160
|
* S1: the authenticated principal every authed surface works with. Built from
|
|
@@ -163,9 +175,19 @@ export interface AuthenticatedDevice {
|
|
|
163
175
|
* S1 shape: the token's `(tenantId, deviceId)` are LOOKUP KEYS into the
|
|
164
176
|
* registry, and the row that comes back is the authority. A token for a
|
|
165
177
|
* device that no longer exists, one whose tenant does not own that device,
|
|
166
|
-
* one whose product disagrees with the row,
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
178
|
+
* one whose product disagrees with the row, one whose row belongs to a
|
|
179
|
+
* different product than this instance serves, and one for a revoked device
|
|
180
|
+
* all fail identically here and are indistinguishable to the caller — there
|
|
181
|
+
* is deliberately no "which of those was it" signal to hand back, so no route
|
|
182
|
+
* can turn a 401 into a cross-tenant (or cross-product) existence oracle.
|
|
183
|
+
*
|
|
184
|
+
* The last two checks are different facts and both are needed. Row vs claims
|
|
185
|
+
* says "the token belongs to this row"; row vs instance says "this row
|
|
186
|
+
* belongs to the product this server serves" — a single server can mint
|
|
187
|
+
* pairing codes for any product (`createPairingCode` takes the claims per
|
|
188
|
+
* code), so a row from another product is a real row holding a real token
|
|
189
|
+
* and is still not a principal here. `conn.hello`'s own product checks
|
|
190
|
+
* (`ws-server.ts`) validate the client's ANNOUNCEMENT, which is a third fact
|
|
191
|
+
* and stays where it is.
|
|
170
192
|
*/
|
|
171
193
|
export declare function authenticateBearer(header: string | undefined, deps: AuthDeps): Promise<AuthenticatedDevice | undefined>;
|
package/dist/hub.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { ByokServerEvent, DispatchInput, HubStats, MachineInfo, TaskHandle,
|
|
|
29
29
|
* handlers below no longer carry their own device-mismatch checks.
|
|
30
30
|
*
|
|
31
31
|
* Outbound delivery (M1, §1.2/§9): every server -> daemon envelope
|
|
32
|
-
* (`conn.ack`, `task.
|
|
32
|
+
* (`conn.ack`, either task offer, `task.approve/reject/cancel/steer`) gets a fresh
|
|
33
33
|
* per-device monotonic `seq` and is retained in a capped ring buffer
|
|
34
34
|
* ({@link OUTBOX_RING_CAPACITY} entries) so it can be redelivered — in `seq`
|
|
35
35
|
* order, skipping anything whose task has since reached a terminal state —
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { randomBytes, randomUUID, timingSafeEqual, createHmac, createHash, createPublicKey, verify } from 'crypto';
|
|
2
|
+
import { nonceSigningBytes } from '@byok-sdk/core';
|
|
2
3
|
import { jwtVerify, SignJWT } from 'jose';
|
|
3
4
|
import { mkdir, writeFile, readFile } from 'fs/promises';
|
|
4
5
|
import { mkdtempSync, mkdirSync, existsSync, chmodSync } from 'fs';
|
|
5
6
|
import { tmpdir } from 'os';
|
|
6
7
|
import path, { dirname } from 'path';
|
|
8
|
+
import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, DispatchSelectionSchema, RequiredToolsetsSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, BYOK_CHALLENGE_PATH, ChallengeRequestSchema, BYOK_TOKEN_PATH, TokenRequestSchema, BYOK_BLOBS_PATH, CreateBlobRequestSchema, BYOK_BLOB_FINALIZE_ROUTE, BYOK_BLOB_URL_ROUTE, BYOK_BLOB_CONTENT_ROUTE, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendRequestSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
|
|
7
9
|
import { Hono } from 'hono';
|
|
8
|
-
import { CAPABILITY_FLAGS, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, createEnvelope, TASK_STATES, PairRequestSchema, ChallengeRequestSchema, TokenRequestSchema, CreateBlobRequestSchema, MessagesSendRequestSchema, decodeEnvelope } from '@byok-sdk/protocol';
|
|
9
10
|
import { WebSocketServer } from 'ws';
|
|
10
11
|
import { createRequire } from 'module';
|
|
11
12
|
|
|
12
13
|
// src/auth.ts
|
|
13
14
|
var ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
14
15
|
var NONCE_TTL_MS = 5 * 60 * 1e3;
|
|
15
|
-
var NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
|
|
16
16
|
function createHmacTokenSigner(secret = randomBytes(32)) {
|
|
17
17
|
return {
|
|
18
18
|
async sign(claims, expiresInSeconds) {
|
|
@@ -143,13 +143,13 @@ function verifyEd25519Signature(devicePublicKey, message, signature) {
|
|
|
143
143
|
key: { kty: "OKP", crv: "Ed25519", x: devicePublicKey },
|
|
144
144
|
format: "jwk"
|
|
145
145
|
});
|
|
146
|
-
return verify(null,
|
|
146
|
+
return verify(null, message, keyObject, Buffer.from(signature, "base64url"));
|
|
147
147
|
} catch {
|
|
148
148
|
return false;
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
function verifyNonceSignature(devicePublicKey, nonce, signature) {
|
|
152
|
-
return verifyEd25519Signature(devicePublicKey,
|
|
152
|
+
return verifyEd25519Signature(devicePublicKey, nonceSigningBytes(nonce), signature);
|
|
153
153
|
}
|
|
154
154
|
function extractBearerToken(header) {
|
|
155
155
|
if (!header) return void 0;
|
|
@@ -164,6 +164,7 @@ async function authenticateBearer(header, deps) {
|
|
|
164
164
|
const device = deps.devices.get(claims.tenantId, claims.deviceId);
|
|
165
165
|
if (!device || device.revoked) return void 0;
|
|
166
166
|
if (device.productId !== claims.productId) return void 0;
|
|
167
|
+
if (device.productId !== deps.productId) return void 0;
|
|
167
168
|
return { deviceId: device.deviceId, tenantId: device.tenantId, productId: device.productId };
|
|
168
169
|
}
|
|
169
170
|
var BlobDeclarationConflictError = class extends Error {
|
|
@@ -247,7 +248,7 @@ var LocalDiskBlobStore = class {
|
|
|
247
248
|
signUrl(blobId, action) {
|
|
248
249
|
const exp = Date.now() + this.urlTtlMs;
|
|
249
250
|
const sig = this.computeSig(blobId, action, exp);
|
|
250
|
-
return
|
|
251
|
+
return `${byokBlobContentPath(blobId)}?sig=${sig}&exp=${exp}`;
|
|
251
252
|
}
|
|
252
253
|
};
|
|
253
254
|
var PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
@@ -342,7 +343,7 @@ function buildHonoApp(deps) {
|
|
|
342
343
|
if (deps.healthzRoute) {
|
|
343
344
|
app.get("/healthz", (c) => c.json({ ok: true, uptimeMs: Date.now() - serverStartedAtMs }, 200));
|
|
344
345
|
}
|
|
345
|
-
app.post(
|
|
346
|
+
app.post(BYOK_PAIR_PATH, async (c) => {
|
|
346
347
|
const parsed = PairRequestSchema.safeParse(await readJsonBody(c));
|
|
347
348
|
if (!parsed.success) {
|
|
348
349
|
return c.json({ error: "pairingCode, deviceName, and devicePublicKey are required strings" }, 400);
|
|
@@ -373,7 +374,7 @@ function buildHonoApp(deps) {
|
|
|
373
374
|
const response = { deviceId, accessToken, refreshHint: expiresAt };
|
|
374
375
|
return c.json(response, 200);
|
|
375
376
|
});
|
|
376
|
-
app.post(
|
|
377
|
+
app.post(BYOK_CHALLENGE_PATH, async (c) => {
|
|
377
378
|
const parsed = ChallengeRequestSchema.safeParse(await readJsonBody(c));
|
|
378
379
|
if (!parsed.success) return c.json({ error: "deviceId is required" }, 400);
|
|
379
380
|
const { deviceId } = parsed.data;
|
|
@@ -385,7 +386,7 @@ function buildHonoApp(deps) {
|
|
|
385
386
|
const response = { nonce };
|
|
386
387
|
return c.json(response, 200);
|
|
387
388
|
});
|
|
388
|
-
app.post(
|
|
389
|
+
app.post(BYOK_TOKEN_PATH, async (c) => {
|
|
389
390
|
const parsed = TokenRequestSchema.safeParse(await readJsonBody(c));
|
|
390
391
|
if (!parsed.success) return c.json({ error: "deviceId, nonce, and signature are required" }, 400);
|
|
391
392
|
const { deviceId, nonce, signature } = parsed.data;
|
|
@@ -408,7 +409,7 @@ function buildHonoApp(deps) {
|
|
|
408
409
|
const response = { accessToken, expiresAt };
|
|
409
410
|
return c.json(response, 200);
|
|
410
411
|
});
|
|
411
|
-
app.post(
|
|
412
|
+
app.post(BYOK_BLOBS_PATH, async (c) => {
|
|
412
413
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
413
414
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
414
415
|
const parsed = CreateBlobRequestSchema.safeParse(await readJsonBody(c));
|
|
@@ -432,7 +433,7 @@ function buildHonoApp(deps) {
|
|
|
432
433
|
throw error;
|
|
433
434
|
}
|
|
434
435
|
});
|
|
435
|
-
app.post(
|
|
436
|
+
app.post(BYOK_BLOB_FINALIZE_ROUTE, async (c) => {
|
|
436
437
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
437
438
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
438
439
|
const reservationId = c.req.header("idempotency-key");
|
|
@@ -448,7 +449,7 @@ function buildHonoApp(deps) {
|
|
|
448
449
|
}
|
|
449
450
|
return c.body(null, 204);
|
|
450
451
|
});
|
|
451
|
-
app.get(
|
|
452
|
+
app.get(BYOK_BLOB_URL_ROUTE, async (c) => {
|
|
452
453
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
453
454
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
454
455
|
const downloadUrl = await deps.blobStore.getDownloadUrl(c.req.param("id"));
|
|
@@ -456,7 +457,7 @@ function buildHonoApp(deps) {
|
|
|
456
457
|
const response = { downloadUrl };
|
|
457
458
|
return c.json(response, 200);
|
|
458
459
|
});
|
|
459
|
-
app.put(
|
|
460
|
+
app.put(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
|
|
460
461
|
const blobId = c.req.param("id");
|
|
461
462
|
const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
|
|
462
463
|
if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "put", sig, exp)) {
|
|
@@ -467,7 +468,7 @@ function buildHonoApp(deps) {
|
|
|
467
468
|
if (!result.ok) return c.json({ error: result.reason }, 422);
|
|
468
469
|
return c.body(null, 204);
|
|
469
470
|
});
|
|
470
|
-
app.get(
|
|
471
|
+
app.get(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
|
|
471
472
|
const blobId = c.req.param("id");
|
|
472
473
|
const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
|
|
473
474
|
if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "get", sig, exp)) {
|
|
@@ -477,7 +478,7 @@ function buildHonoApp(deps) {
|
|
|
477
478
|
if (!content) return c.json({ error: "blob not found" }, 404);
|
|
478
479
|
return c.body(new Uint8Array(content.data), 200, { "content-type": content.contentType });
|
|
479
480
|
});
|
|
480
|
-
app.get(
|
|
481
|
+
app.get(BYOK_EVENTS_PATH, async (c) => {
|
|
481
482
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
482
483
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
483
484
|
const cursorRaw = c.req.query("cursor");
|
|
@@ -491,7 +492,7 @@ function buildHonoApp(deps) {
|
|
|
491
492
|
const response = result;
|
|
492
493
|
return c.json(response, 200);
|
|
493
494
|
});
|
|
494
|
-
app.post(
|
|
495
|
+
app.post(BYOK_MESSAGES_PATH, async (c) => {
|
|
495
496
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
496
497
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
497
498
|
const parsed = MessagesSendRequestSchema.safeParse(await readJsonBody(c));
|
|
@@ -1274,7 +1275,17 @@ var ConnectionHub = class {
|
|
|
1274
1275
|
state: "Complete",
|
|
1275
1276
|
summary: payload.summary,
|
|
1276
1277
|
sessionRef: payload.sessionRef,
|
|
1277
|
-
artifactRefs: payload.artifactRefs
|
|
1278
|
+
artifactRefs: payload.artifactRefs,
|
|
1279
|
+
// additive-minor (`task.complete.document`): projected verbatim, the
|
|
1280
|
+
// same way `summary`/`artifactRefs` are. Nothing to validate or
|
|
1281
|
+
// measure here — the payload only got this far because
|
|
1282
|
+
// `TaskCompletePayloadSchema`'s own refinement already enforced
|
|
1283
|
+
// JSON-serializability and `RESULT_DOCUMENT_MAX_BYTES` at the inbound
|
|
1284
|
+
// boundary, and re-checking would make this a second authority for a
|
|
1285
|
+
// rule the wire already owns. Stays `undefined` for the two cases that
|
|
1286
|
+
// never carry one: a daemon with no extractor configured, and a
|
|
1287
|
+
// pre-`result-document` daemon build.
|
|
1288
|
+
document: payload.document
|
|
1278
1289
|
};
|
|
1279
1290
|
this.applyOrFail(taskId, "Complete", { result, sessionRef: payload.sessionRef });
|
|
1280
1291
|
}
|
|
@@ -1658,19 +1669,38 @@ var ConnectionHub = class {
|
|
|
1658
1669
|
// dispatch() and the TaskHandle it returns
|
|
1659
1670
|
// ---------------------------------------------------------------------
|
|
1660
1671
|
async dispatch(input) {
|
|
1672
|
+
const dispatchSelection = input.dispatchSelection === void 0 ? void 0 : DispatchSelectionSchema.parse(input.dispatchSelection);
|
|
1673
|
+
const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
|
|
1661
1674
|
const deviceId = input.deviceId ?? this.pickFirstConnectedDevice();
|
|
1662
1675
|
if (!deviceId || !this.connections.get(deviceId)?.connected) {
|
|
1663
1676
|
throw new Error(
|
|
1664
1677
|
deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
|
|
1665
1678
|
);
|
|
1666
1679
|
}
|
|
1680
|
+
if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
|
|
1681
|
+
throw new Error(
|
|
1682
|
+
`device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
if (requiredToolsets !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("toolset-selection") ?? false)) {
|
|
1686
|
+
throw new Error(
|
|
1687
|
+
`device ${deviceId} did not advertise toolset-selection capability; refusing a task whose semantics require local MCP tools`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
if (dispatchSelection !== void 0 && input.runtime !== void 0 && input.runtime !== dispatchSelection.runtimeId) {
|
|
1691
|
+
throw new Error(
|
|
1692
|
+
`dispatch runtime ${input.runtime} does not match dispatchSelection.runtimeId ${dispatchSelection.runtimeId}`
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1667
1695
|
const taskId = generateTaskId();
|
|
1668
1696
|
const policy = input.policy ?? DEFAULT_POLICY;
|
|
1697
|
+
const runtime = dispatchSelection?.runtimeId ?? input.runtime;
|
|
1669
1698
|
const record = this.taskStore.create({
|
|
1670
1699
|
taskId,
|
|
1671
1700
|
instruction: input.instruction,
|
|
1672
|
-
runtime
|
|
1701
|
+
runtime,
|
|
1673
1702
|
policy,
|
|
1703
|
+
requiredToolsets,
|
|
1674
1704
|
deviceId,
|
|
1675
1705
|
sessionRef: input.sessionRef
|
|
1676
1706
|
});
|
|
@@ -1682,17 +1712,23 @@ var ConnectionHub = class {
|
|
|
1682
1712
|
this.runtimes.set(taskId, { queue, resolveResult, result });
|
|
1683
1713
|
queue.push({ kind: "state", state: record.state, at: record.createdAt });
|
|
1684
1714
|
this.serverEvents.push({ kind: "task.created", taskId, at: record.createdAt });
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1715
|
+
const commonOffer = {
|
|
1716
|
+
instruction: input.instruction,
|
|
1717
|
+
policy,
|
|
1718
|
+
runtime,
|
|
1719
|
+
dispatchSelection,
|
|
1720
|
+
sessionRef: input.sessionRef
|
|
1721
|
+
};
|
|
1722
|
+
if (requiredToolsets === void 0) {
|
|
1723
|
+
this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef: input.sessionRef });
|
|
1724
|
+
} else {
|
|
1725
|
+
this.sendToDevice(
|
|
1726
|
+
deviceId,
|
|
1727
|
+
"task.offer_with_toolsets",
|
|
1728
|
+
{ ...commonOffer, requiredToolsets },
|
|
1729
|
+
{ taskId, sessionRef: input.sessionRef }
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1696
1732
|
return this.buildTaskHandle(taskId);
|
|
1697
1733
|
}
|
|
1698
1734
|
buildTaskHandle(taskId) {
|
|
@@ -2008,6 +2044,7 @@ var InMemoryTaskStore = class {
|
|
|
2008
2044
|
instruction: input.instruction,
|
|
2009
2045
|
runtime: input.runtime,
|
|
2010
2046
|
policy: input.policy,
|
|
2047
|
+
requiredToolsets: input.requiredToolsets,
|
|
2011
2048
|
deviceId: input.deviceId,
|
|
2012
2049
|
sessionRef: input.sessionRef,
|
|
2013
2050
|
createdAt: now,
|
|
@@ -2101,7 +2138,7 @@ function startHeartbeat(ws, opts = {}) {
|
|
|
2101
2138
|
}
|
|
2102
2139
|
|
|
2103
2140
|
// src/ws-server.ts
|
|
2104
|
-
var WS_PATH =
|
|
2141
|
+
var WS_PATH = BYOK_WS_PATH;
|
|
2105
2142
|
var SUPPORTED_CAPABILITIES = [...CAPABILITY_FLAGS];
|
|
2106
2143
|
function matchesWsPath(url) {
|
|
2107
2144
|
return url.split("?")[0] === WS_PATH;
|
|
@@ -2199,6 +2236,14 @@ function handleConnection(ws, principal, deps) {
|
|
|
2199
2236
|
}
|
|
2200
2237
|
});
|
|
2201
2238
|
}
|
|
2239
|
+
function closeSqliteDatabaseAfterInitializationFailure(db, initializationError, message, close = (database) => database.close()) {
|
|
2240
|
+
try {
|
|
2241
|
+
close(db);
|
|
2242
|
+
} catch (closeError) {
|
|
2243
|
+
throw new AggregateError([initializationError, closeError], message);
|
|
2244
|
+
}
|
|
2245
|
+
throw initializationError;
|
|
2246
|
+
}
|
|
2202
2247
|
var SqliteUnavailableError = class extends Error {
|
|
2203
2248
|
constructor(cause) {
|
|
2204
2249
|
super(
|
|
@@ -2238,16 +2283,27 @@ function loadSqliteModule() {
|
|
|
2238
2283
|
var DEFAULT_BUSY_TIMEOUT_MS = 5e3;
|
|
2239
2284
|
var SECURE_FILE_MODE = 384;
|
|
2240
2285
|
var SECURE_DIR_MODE = 448;
|
|
2241
|
-
function openSqliteDatabase(path2, options) {
|
|
2286
|
+
function openSqliteDatabase(path2, options, faults) {
|
|
2242
2287
|
const { DatabaseSync } = loadSqliteModule();
|
|
2243
2288
|
if (path2 !== ":memory:") {
|
|
2244
2289
|
mkdirSync(dirname(path2), { recursive: true, mode: SECURE_DIR_MODE });
|
|
2245
2290
|
}
|
|
2246
2291
|
const db = new DatabaseSync(path2, { timeout: DEFAULT_BUSY_TIMEOUT_MS, ...options });
|
|
2247
|
-
|
|
2248
|
-
|
|
2292
|
+
try {
|
|
2293
|
+
faults?.onStep?.("after-open");
|
|
2294
|
+
if (path2 !== ":memory:") {
|
|
2295
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
2296
|
+
faults?.onStep?.("after-wal");
|
|
2297
|
+
}
|
|
2298
|
+
return db;
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
closeSqliteDatabaseAfterInitializationFailure(
|
|
2301
|
+
db,
|
|
2302
|
+
error,
|
|
2303
|
+
"SQLite open initialization failed and its native handle could not be closed",
|
|
2304
|
+
faults?.close
|
|
2305
|
+
);
|
|
2249
2306
|
}
|
|
2250
|
-
return db;
|
|
2251
2307
|
}
|
|
2252
2308
|
function secureSqliteFilePermissions(dbPath) {
|
|
2253
2309
|
if (dbPath === ":memory:") return;
|
|
@@ -2265,6 +2321,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
2265
2321
|
state TEXT NOT NULL,
|
|
2266
2322
|
instruction TEXT NOT NULL,
|
|
2267
2323
|
runtime TEXT,
|
|
2324
|
+
required_toolsets_json TEXT,
|
|
2268
2325
|
policy_json TEXT NOT NULL,
|
|
2269
2326
|
device_id TEXT,
|
|
2270
2327
|
session_ref TEXT,
|
|
@@ -2279,6 +2336,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
2279
2336
|
var ADDITIVE_COLUMNS = [
|
|
2280
2337
|
{ name: "pending_approval_id", ddl: "ALTER TABLE tasks ADD COLUMN pending_approval_id TEXT" },
|
|
2281
2338
|
{ name: "claimed_runtime", ddl: "ALTER TABLE tasks ADD COLUMN claimed_runtime TEXT" },
|
|
2339
|
+
{ name: "required_toolsets_json", ddl: "ALTER TABLE tasks ADD COLUMN required_toolsets_json TEXT" },
|
|
2282
2340
|
// S0 (GAP-002): the claim-time `RuntimeCapabilities` snapshot, stored as
|
|
2283
2341
|
// the JSON text of that closed, protocol-validated object (same idiom as
|
|
2284
2342
|
// `policy_json`/`result_json` above — no column-per-flag, so a future
|
|
@@ -2311,12 +2369,14 @@ function ensureAdditiveColumns(db) {
|
|
|
2311
2369
|
}
|
|
2312
2370
|
function rowToRecord(row) {
|
|
2313
2371
|
const resultJson = row.result_json;
|
|
2372
|
+
const requiredToolsetsJson = row.required_toolsets_json;
|
|
2314
2373
|
const claimedRuntimeCapabilitiesJson = row.claimed_runtime_capabilities_json;
|
|
2315
2374
|
return {
|
|
2316
2375
|
taskId: row.task_id,
|
|
2317
2376
|
state: row.state,
|
|
2318
2377
|
instruction: row.instruction,
|
|
2319
2378
|
runtime: row.runtime ?? void 0,
|
|
2379
|
+
requiredToolsets: requiredToolsetsJson ? JSON.parse(requiredToolsetsJson) : void 0,
|
|
2320
2380
|
policy: JSON.parse(row.policy_json),
|
|
2321
2381
|
deviceId: row.device_id ?? void 0,
|
|
2322
2382
|
sessionRef: row.session_ref ?? void 0,
|
|
@@ -2335,28 +2395,37 @@ var SqliteTaskStore = class {
|
|
|
2335
2395
|
selectStmt;
|
|
2336
2396
|
selectAllStmt;
|
|
2337
2397
|
updatePendingApprovalIdStmt;
|
|
2398
|
+
closed = false;
|
|
2338
2399
|
constructor(opts) {
|
|
2339
2400
|
this.db = openSqliteDatabase(opts.path);
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2401
|
+
try {
|
|
2402
|
+
this.db.exec(SCHEMA);
|
|
2403
|
+
ensureAdditiveColumns(this.db);
|
|
2404
|
+
secureSqliteFilePermissions(opts.path);
|
|
2405
|
+
this.insertStmt = this.db.prepare(
|
|
2406
|
+
`INSERT INTO tasks
|
|
2407
|
+
(task_id, state, instruction, runtime, required_toolsets_json, policy_json, device_id, session_ref, created_at, updated_at, result_json)
|
|
2408
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2409
|
+
);
|
|
2410
|
+
this.updateStmt = this.db.prepare(
|
|
2411
|
+
`UPDATE tasks SET
|
|
2412
|
+
state = ?, instruction = ?, runtime = ?, required_toolsets_json = ?, policy_json = ?, device_id = ?,
|
|
2413
|
+
session_ref = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
|
|
2414
|
+
claimed_runtime = ?, claimed_runtime_capabilities_json = ?
|
|
2415
|
+
WHERE task_id = ? AND state = ?`
|
|
2416
|
+
);
|
|
2417
|
+
this.selectStmt = this.db.prepare("SELECT * FROM tasks WHERE task_id = ?");
|
|
2418
|
+
this.selectAllStmt = this.db.prepare("SELECT * FROM tasks ORDER BY rowid ASC");
|
|
2419
|
+
this.updatePendingApprovalIdStmt = this.db.prepare(
|
|
2420
|
+
"UPDATE tasks SET pending_approval_id = ?, updated_at = ? WHERE task_id = ? AND state = 'AwaitApproval'"
|
|
2421
|
+
);
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
closeSqliteDatabaseAfterInitializationFailure(
|
|
2424
|
+
this.db,
|
|
2425
|
+
error,
|
|
2426
|
+
"SqliteTaskStore initialization failed and its native handle could not be closed"
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2360
2429
|
}
|
|
2361
2430
|
create(input) {
|
|
2362
2431
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2365,6 +2434,7 @@ var SqliteTaskStore = class {
|
|
|
2365
2434
|
state: "Offered",
|
|
2366
2435
|
instruction: input.instruction,
|
|
2367
2436
|
runtime: input.runtime,
|
|
2437
|
+
requiredToolsets: input.requiredToolsets,
|
|
2368
2438
|
policy: input.policy,
|
|
2369
2439
|
deviceId: input.deviceId,
|
|
2370
2440
|
sessionRef: input.sessionRef,
|
|
@@ -2376,6 +2446,7 @@ var SqliteTaskStore = class {
|
|
|
2376
2446
|
record.state,
|
|
2377
2447
|
record.instruction,
|
|
2378
2448
|
record.runtime ?? null,
|
|
2449
|
+
record.requiredToolsets ? JSON.stringify(record.requiredToolsets) : null,
|
|
2379
2450
|
JSON.stringify(record.policy),
|
|
2380
2451
|
record.deviceId ?? null,
|
|
2381
2452
|
record.sessionRef ?? null,
|
|
@@ -2433,6 +2504,7 @@ var SqliteTaskStore = class {
|
|
|
2433
2504
|
updated.state,
|
|
2434
2505
|
updated.instruction,
|
|
2435
2506
|
updated.runtime ?? null,
|
|
2507
|
+
updated.requiredToolsets ? JSON.stringify(updated.requiredToolsets) : null,
|
|
2436
2508
|
JSON.stringify(updated.policy),
|
|
2437
2509
|
updated.deviceId ?? null,
|
|
2438
2510
|
updated.sessionRef ?? null,
|
|
@@ -2481,7 +2553,9 @@ var SqliteTaskStore = class {
|
|
|
2481
2553
|
* instance against the same file, or on process shutdown.
|
|
2482
2554
|
*/
|
|
2483
2555
|
close() {
|
|
2556
|
+
if (this.closed) return;
|
|
2484
2557
|
this.db.close();
|
|
2558
|
+
this.closed = true;
|
|
2485
2559
|
}
|
|
2486
2560
|
};
|
|
2487
2561
|
var SCHEMA2 = `
|
|
@@ -2530,21 +2604,30 @@ var SqliteBlobStore = class {
|
|
|
2530
2604
|
selectBlobStmt;
|
|
2531
2605
|
selectUploadedStmt;
|
|
2532
2606
|
writeContentStmt;
|
|
2607
|
+
closed = false;
|
|
2533
2608
|
constructor(opts) {
|
|
2534
2609
|
this.db = openSqliteDatabase(opts.path);
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2610
|
+
try {
|
|
2611
|
+
this.db.exec(SCHEMA2);
|
|
2612
|
+
secureSqliteFilePermissions(opts.path);
|
|
2613
|
+
this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS2;
|
|
2614
|
+
this.secret = opts.signingKey ?? loadOrCreateSigningSecret(this.db);
|
|
2615
|
+
this.insertBlobStmt = this.db.prepare(
|
|
2616
|
+
`INSERT INTO blobs (blob_id, size, content_type, content_hash, uploaded, data)
|
|
2617
|
+
VALUES (?, ?, ?, ?, 0, NULL)`
|
|
2618
|
+
);
|
|
2619
|
+
this.selectBlobStmt = this.db.prepare(
|
|
2620
|
+
"SELECT size, content_type, content_hash, uploaded, data FROM blobs WHERE blob_id = ?"
|
|
2621
|
+
);
|
|
2622
|
+
this.selectUploadedStmt = this.db.prepare("SELECT uploaded FROM blobs WHERE blob_id = ?");
|
|
2623
|
+
this.writeContentStmt = this.db.prepare("UPDATE blobs SET data = ?, uploaded = 1 WHERE blob_id = ?");
|
|
2624
|
+
} catch (error) {
|
|
2625
|
+
closeSqliteDatabaseAfterInitializationFailure(
|
|
2626
|
+
this.db,
|
|
2627
|
+
error,
|
|
2628
|
+
"SqliteBlobStore initialization failed and its native handle could not be closed"
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2548
2631
|
}
|
|
2549
2632
|
async createUpload(input, requestedBlobId) {
|
|
2550
2633
|
const blobId = requestedBlobId ?? `blob_${randomUUID()}`;
|
|
@@ -2596,7 +2679,9 @@ var SqliteBlobStore = class {
|
|
|
2596
2679
|
}
|
|
2597
2680
|
/** Close the underlying database connection — see `SqliteTaskStore.close`'s doc comment; same rationale. */
|
|
2598
2681
|
close() {
|
|
2682
|
+
if (this.closed) return;
|
|
2599
2683
|
this.db.close();
|
|
2684
|
+
this.closed = true;
|
|
2600
2685
|
}
|
|
2601
2686
|
computeSig(blobId, action, exp) {
|
|
2602
2687
|
return createHmac("sha256", this.secret).update(`${blobId}:${action}:${exp}`).digest("base64url");
|
|
@@ -2604,7 +2689,7 @@ var SqliteBlobStore = class {
|
|
|
2604
2689
|
signUrl(blobId, action) {
|
|
2605
2690
|
const exp = Date.now() + this.urlTtlMs;
|
|
2606
2691
|
const sig = this.computeSig(blobId, action, exp);
|
|
2607
|
-
return
|
|
2692
|
+
return `${byokBlobContentPath(blobId)}?sig=${sig}&exp=${exp}`;
|
|
2608
2693
|
}
|
|
2609
2694
|
};
|
|
2610
2695
|
|
|
@@ -2629,6 +2714,11 @@ function createByokServer(opts) {
|
|
|
2629
2714
|
devices,
|
|
2630
2715
|
nonces,
|
|
2631
2716
|
tokenSigner,
|
|
2717
|
+
// S1: the product this instance serves is part of `authenticateBearer`'s
|
|
2718
|
+
// decision (`auth.ts`), not just of the WS hello gate — so every
|
|
2719
|
+
// bearer-authed route gets the same instance-equality guarantee the WS
|
|
2720
|
+
// upgrade already had transitively.
|
|
2721
|
+
productId: opts.productId,
|
|
2632
2722
|
blobStore,
|
|
2633
2723
|
maxBlobSizeBytes,
|
|
2634
2724
|
longPollHoldMs,
|