@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 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 client signs the same literal (`packages/client/src/daemon/device-keys.ts`).
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 declare const NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
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
- * {@link NONCE_SIGNING_DOMAIN} followed by the nonce. Applying the domain here
137
- * rather than at the call site is the point — there is one place that decides
138
- * what a device signature over a nonce means, so no route can be written that
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, and one for a revoked device all
167
- * fail identically here and are indistinguishable to the caller there is
168
- * deliberately no "which of those was it" signal to hand back, so no route
169
- * can turn a 401 into a cross-tenant existence oracle.
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.offer/approve/reject/cancel/steer`) gets a fresh
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, Buffer.from(message, "utf8"), keyObject, Buffer.from(signature, "base64url"));
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, NONCE_SIGNING_DOMAIN + nonce, signature);
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 `/byok/blobs/${blobId}/content?sig=${sig}&exp=${exp}`;
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("/byok/pair", async (c) => {
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("/byok/challenge", async (c) => {
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("/byok/token", async (c) => {
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("/byok/blobs", async (c) => {
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("/byok/blobs/:id/finalize", async (c) => {
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("/byok/blobs/:id/url", async (c) => {
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("/byok/blobs/:id/content", async (c) => {
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("/byok/blobs/:id/content", async (c) => {
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("/byok/events", async (c) => {
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("/byok/messages", async (c) => {
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: input.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
- this.sendToDevice(
1686
- deviceId,
1687
- "task.offer",
1688
- {
1689
- instruction: input.instruction,
1690
- policy,
1691
- runtime: input.runtime,
1692
- sessionRef: input.sessionRef
1693
- },
1694
- { taskId, sessionRef: input.sessionRef }
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 = "/byok/ws";
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
- if (path2 !== ":memory:") {
2248
- db.exec("PRAGMA journal_mode = WAL;");
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
- this.db.exec(SCHEMA);
2341
- ensureAdditiveColumns(this.db);
2342
- secureSqliteFilePermissions(opts.path);
2343
- this.insertStmt = this.db.prepare(
2344
- `INSERT INTO tasks
2345
- (task_id, state, instruction, runtime, policy_json, device_id, session_ref, created_at, updated_at, result_json)
2346
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2347
- );
2348
- this.updateStmt = this.db.prepare(
2349
- `UPDATE tasks SET
2350
- state = ?, instruction = ?, runtime = ?, policy_json = ?, device_id = ?,
2351
- session_ref = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2352
- claimed_runtime = ?, claimed_runtime_capabilities_json = ?
2353
- WHERE task_id = ? AND state = ?`
2354
- );
2355
- this.selectStmt = this.db.prepare("SELECT * FROM tasks WHERE task_id = ?");
2356
- this.selectAllStmt = this.db.prepare("SELECT * FROM tasks ORDER BY rowid ASC");
2357
- this.updatePendingApprovalIdStmt = this.db.prepare(
2358
- "UPDATE tasks SET pending_approval_id = ?, updated_at = ? WHERE task_id = ? AND state = 'AwaitApproval'"
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
- this.db.exec(SCHEMA2);
2536
- secureSqliteFilePermissions(opts.path);
2537
- this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS2;
2538
- this.secret = opts.signingKey ?? loadOrCreateSigningSecret(this.db);
2539
- this.insertBlobStmt = this.db.prepare(
2540
- `INSERT INTO blobs (blob_id, size, content_type, content_hash, uploaded, data)
2541
- VALUES (?, ?, ?, ?, 0, NULL)`
2542
- );
2543
- this.selectBlobStmt = this.db.prepare(
2544
- "SELECT size, content_type, content_hash, uploaded, data FROM blobs WHERE blob_id = ?"
2545
- );
2546
- this.selectUploadedStmt = this.db.prepare("SELECT uploaded FROM blobs WHERE blob_id = ?");
2547
- this.writeContentStmt = this.db.prepare("UPDATE blobs SET data = ?, uploaded = 1 WHERE blob_id = ?");
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 `/byok/blobs/${blobId}/content?sig=${sig}&exp=${exp}`;
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,