@byok-sdk/server 0.8.0-beta.0 → 0.8.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/index.js CHANGED
@@ -5,7 +5,7 @@ import { mkdir, writeFile, readFile } from 'fs/promises';
5
5
  import { mkdtempSync, mkdirSync, existsSync, chmodSync } from 'fs';
6
6
  import { tmpdir } from 'os';
7
7
  import path, { dirname } from 'path';
8
- import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, AgentRefSchema, AgentEgressPolicySchema, DispatchSelectionSchema, RequiredToolsetsSchema, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, AgentContentReadPayloadSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, PairResponseSchema, 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, AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, PairResponseTenantIdSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
8
+ import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, AgentRefSchema, AgentEgressPolicySchema, DispatchSelectionSchema, RequiredToolsetsSchema, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, AgentContentReadPayloadSchema, AgentHomeProjectionPayloadSchema, AGENT_HOME_PROJECTION_CAPABILITY, AgentHomeProjectionReadbackSchema, AgentHomeProjectionCompletionRequestSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, PairResponseSchema, 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, BYOK_AGENT_HOME_PROJECTION_COMPLETION_ROUTE, AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, PairResponseTenantIdSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
9
9
  import { Hono } from 'hono';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { createRequire } from 'module';
@@ -251,6 +251,53 @@ var LocalDiskBlobStore = class {
251
251
  return `${byokBlobContentPath(blobId)}?sig=${sig}&exp=${exp}`;
252
252
  }
253
253
  };
254
+
255
+ // src/event-queue.ts
256
+ var AsyncEventQueue = class {
257
+ buffer = [];
258
+ closed = false;
259
+ waiters = [];
260
+ push(value) {
261
+ if (this.closed) return;
262
+ this.buffer.push(value);
263
+ this.wake();
264
+ }
265
+ close() {
266
+ if (this.closed) return;
267
+ this.closed = true;
268
+ this.wake();
269
+ }
270
+ wake() {
271
+ const waiters = this.waiters;
272
+ this.waiters = [];
273
+ for (const resolve of waiters) resolve();
274
+ }
275
+ waitForMore() {
276
+ return new Promise((resolve) => this.waiters.push(resolve));
277
+ }
278
+ /** Async-iterate the buffer from index 0, waiting for new pushes until closed. */
279
+ subscribe() {
280
+ const queue = this;
281
+ return {
282
+ [Symbol.asyncIterator]() {
283
+ let index = 0;
284
+ return {
285
+ async next() {
286
+ for (; ; ) {
287
+ if (index < queue.buffer.length) {
288
+ return { value: queue.buffer[index++], done: false };
289
+ }
290
+ if (queue.closed) {
291
+ return { value: void 0, done: true };
292
+ }
293
+ await queue.waitForMore();
294
+ }
295
+ }
296
+ };
297
+ }
298
+ };
299
+ }
300
+ };
254
301
  var PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
255
302
  function generatePairingCode(length = 8) {
256
303
  const bytes = randomBytes(length);
@@ -266,2079 +313,2137 @@ function generateDeviceId() {
266
313
  function generateTaskId() {
267
314
  return `task_${randomUUID()}`;
268
315
  }
269
- var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
270
- var PairingCodeInvalidError = class extends Error {
271
- constructor(reason) {
272
- super(`invalid pairing code: ${reason}`);
273
- this.name = "PairingCodeInvalidError";
274
- }
275
- };
276
- var PairingManager = class {
277
- codes = /* @__PURE__ */ new Map();
316
+
317
+ // src/rate-limiter.ts
318
+ var DEFAULT_MESSAGES_PER_SECOND = 50;
319
+ var DEFAULT_BURST = 100;
320
+ var DEFAULT_MAX_TRACKED_DEVICES = 1e4;
321
+ var EVICTION_SWEEP_EVERY_N_CALLS = 1e3;
322
+ var RateLimiter = class {
323
+ messagesPerSecond;
324
+ burst;
325
+ buckets = /* @__PURE__ */ new Map();
278
326
  /**
279
- * Mint a single-use code bound to `claims`. Claims are REQUIRED — a
280
- * claimless mint is a compile error, and (for a JS caller, or a claims
281
- * object assembled from untyped config) a runtime {@link TypeError}. There
282
- * is no default tenant and no default product: a device with no tenant
283
- * must be inexpressible, so the failure happens here, at the mint, rather
284
- * than being filled in downstream.
327
+ * Wall-clock idle duration (ms) after which a bucket is GUARANTEED to
328
+ * already be refilled to `burst`, regardless of its actual token count at
329
+ * last touch — i.e. the time to go from 0 tokens to `burst` at this
330
+ * instance's configured rate. `evictIdleBucketsIfDue` uses this as the
331
+ * eviction threshold: dropping an entry idle at least this long and
332
+ * recreating it fresh (tokens = burst) on the next `consume()` is
333
+ * therefore behaviorally IDENTICAL to refilling it in place would have
334
+ * been — both cap at `burst` — so eviction is semantically invisible to
335
+ * the caller.
285
336
  */
286
- createPairingCode(claims) {
287
- const validated = validatePairingCodeClaims(claims);
288
- const code = generatePairingCode();
289
- const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
290
- this.codes.set(code, { code, claims: validated, expiresAt, used: false });
291
- return { code, expiresAt: new Date(expiresAt).toISOString() };
337
+ idleEvictionThresholdMs;
338
+ /** Finding R5: hard cap on `buckets.size` — see {@link DEFAULT_MAX_TRACKED_DEVICES}'s own doc comment. */
339
+ maxTrackedDevices;
340
+ /** Calls to `consume()` since the last sweep — see `EVICTION_SWEEP_EVERY_N_CALLS`. */
341
+ callsSinceSweep = 0;
342
+ constructor(opts = {}) {
343
+ const messagesPerSecond = opts.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND;
344
+ const burst = opts.burst ?? DEFAULT_BURST;
345
+ const maxTrackedDevices = opts.maxTrackedDevices ?? DEFAULT_MAX_TRACKED_DEVICES;
346
+ if (!Number.isFinite(messagesPerSecond) || messagesPerSecond <= 0) {
347
+ throw new TypeError(`RateLimiter: messagesPerSecond must be a finite number > 0, got ${messagesPerSecond}`);
348
+ }
349
+ if (!Number.isFinite(burst) || burst < 1) {
350
+ throw new TypeError(`RateLimiter: burst must be a finite number >= 1, got ${burst}`);
351
+ }
352
+ if (!Number.isFinite(maxTrackedDevices) || maxTrackedDevices < 1) {
353
+ throw new TypeError(`RateLimiter: maxTrackedDevices must be a finite number >= 1, got ${maxTrackedDevices}`);
354
+ }
355
+ this.messagesPerSecond = messagesPerSecond;
356
+ this.burst = burst;
357
+ this.maxTrackedDevices = maxTrackedDevices;
358
+ this.idleEvictionThresholdMs = burst / messagesPerSecond * 1e3;
292
359
  }
293
360
  /**
294
- * Validate and consume a pairing code, returning the {@link PairingCodeClaims}
295
- * it was minted with. Throws {@link PairingCodeInvalidError} if the code is
296
- * unknown, expired, or already used callers (the HTTP handler) map that to
297
- * a 401. Single-use is what makes the caller's "redeem, then register the
298
- * device row with these claims" sequence safe: a second redeem of the same
299
- * code can never reach the registration step at all.
361
+ * Debit one token from `key`'s bucket, refilling first for however much
362
+ * wall-clock time has elapsed since its last refill. Returns `false`
363
+ * (and debits nothing) when the bucket is currently empty the caller is
364
+ * over budget right now.
300
365
  */
301
- redeemPairingCode(code) {
302
- const record = this.codes.get(code);
303
- if (!record) {
304
- throw new PairingCodeInvalidError("unknown code");
366
+ consume(key) {
367
+ const now = Date.now();
368
+ this.evictIdleBucketsIfDue(now);
369
+ let bucket = this.buckets.get(key);
370
+ if (!bucket) {
371
+ this.evictOldestIfAtCapacity();
372
+ bucket = { tokens: this.burst, lastRefillMs: now };
373
+ this.buckets.set(key, bucket);
374
+ } else {
375
+ const elapsedMs = now - bucket.lastRefillMs;
376
+ if (elapsedMs > 0) {
377
+ bucket.tokens = Math.min(this.burst, bucket.tokens + elapsedMs / 1e3 * this.messagesPerSecond);
378
+ bucket.lastRefillMs = now;
379
+ }
305
380
  }
306
- if (record.used) {
307
- throw new PairingCodeInvalidError("code already used");
381
+ if (bucket.tokens < 1) return false;
382
+ bucket.tokens -= 1;
383
+ return true;
384
+ }
385
+ /**
386
+ * Every `EVICTION_SWEEP_EVERY_N_CALLS` calls to `consume()`, drops every
387
+ * bucket idle for at least `idleEvictionThresholdMs` (see that field's doc
388
+ * comment for why this is safe). Without this, `buckets` would hold one
389
+ * permanent entry per historical key forever — every device that ever
390
+ * connected, even long after it disconnected for good — growing without
391
+ * bound over a long-lived server's lifetime.
392
+ */
393
+ evictIdleBucketsIfDue(now) {
394
+ this.callsSinceSweep++;
395
+ if (this.callsSinceSweep < EVICTION_SWEEP_EVERY_N_CALLS) return;
396
+ this.callsSinceSweep = 0;
397
+ for (const [key, bucket] of this.buckets) {
398
+ if (now - bucket.lastRefillMs >= this.idleEvictionThresholdMs) {
399
+ this.buckets.delete(key);
400
+ }
308
401
  }
309
- if (Date.now() > record.expiresAt) {
310
- throw new PairingCodeInvalidError("code expired");
402
+ }
403
+ /**
404
+ * Finding R5 (cross-model re-review — F10 residual): called right before
405
+ * inserting a bucket for a genuinely NEW key, evicting the single
406
+ * LEAST-RECENTLY-refilled entry if `buckets` is already at
407
+ * `maxTrackedDevices` — an O(n) scan, but one that only ever runs once
408
+ * the map is already at its hard ceiling (a rare/bounded event under
409
+ * ordinary operation, not a per-call cost), mirroring this codebase's own
410
+ * established "acceptable O(n) for a rare/bounded case" precedent (e.g.
411
+ * `audit-log.ts`'s `compactPreservingLiveTasks` during rotation).
412
+ *
413
+ * Equivalence split (stated explicitly, not left implied):
414
+ * - For any evicted bucket that was ALREADY idle for at least
415
+ * `idleEvictionThresholdMs` (i.e. `evictIdleBucketsIfDue` would have
416
+ * reclaimed it anyway, just not yet — sweeps only run every
417
+ * `EVICTION_SWEEP_EVERY_N_CALLS` calls, not continuously), eviction is
418
+ * PROVABLY equivalent to an in-place refill: both cap at `burst`, so a
419
+ * caller can never observe the difference (see `idleEvictionThresholdMs`'s
420
+ * own doc comment for the identical reasoning `evictIdleBucketsIfDue`
421
+ * already relies on).
422
+ * - For a bucket evicted EARLY — still within its idle threshold, forced
423
+ * out only because `buckets` is at capacity (many thousands of
424
+ * genuinely-distinct, actively-used keys, not a quiet one) — this is
425
+ * BEST-EFFORT, not equivalence-preserving: whatever partial token debt
426
+ * that key had is discarded, and its very next `consume()` call starts
427
+ * completely fresh (`tokens: this.burst`), a strictly MORE permissive
428
+ * outcome than if it had kept its place. This is an accepted,
429
+ * deliberately bounded trade-off — it only ever engages under
430
+ * cardinality far beyond any plausible real deployment — favoring
431
+ * bounded memory over perfect per-key continuity in that one extreme
432
+ * case.
433
+ */
434
+ evictOldestIfAtCapacity() {
435
+ if (this.buckets.size < this.maxTrackedDevices) return;
436
+ let oldestKey;
437
+ let oldestLastRefillMs = Infinity;
438
+ for (const [key, bucket] of this.buckets) {
439
+ if (bucket.lastRefillMs < oldestLastRefillMs) {
440
+ oldestLastRefillMs = bucket.lastRefillMs;
441
+ oldestKey = key;
442
+ }
311
443
  }
312
- record.used = true;
313
- return record.claims;
444
+ if (oldestKey !== void 0) this.buckets.delete(oldestKey);
314
445
  }
315
446
  };
316
- function validatePairingCodeClaims(claims) {
317
- if (typeof claims !== "object" || claims === null) {
318
- throw new TypeError("createPairingCode requires { tenantId, productId } claims");
319
- }
320
- const { tenantId, productId } = claims;
321
- const tenantResult = PairResponseTenantIdSchema.safeParse(tenantId);
322
- if (!tenantResult.success) {
323
- throw new TypeError("createPairingCode requires a valid bounded tenantId");
324
- }
325
- if (typeof productId !== "string" || productId.length === 0) {
326
- throw new TypeError("createPairingCode requires a non-empty productId");
327
- }
328
- return { tenantId: tenantResult.data, productId };
329
- }
330
447
 
331
- // src/http.ts
332
- async function readJsonBody(c) {
333
- try {
334
- return await c.req.json();
335
- } catch {
336
- return void 0;
448
+ // src/hub.ts
449
+ var DEFAULT_POLICY = { mode: "confirm" };
450
+ var OUTBOX_RING_CAPACITY = 500;
451
+ var DEDUP_RING_CAPACITY = 1024;
452
+ var MAX_LEASE_REAPER_SWEEP_INTERVAL_MS = 3e4;
453
+ function isTerminal(state) {
454
+ return state === "Complete" || state === "Failed" || state === "Cancelled";
455
+ }
456
+ function isClaimedState(state) {
457
+ return state === "Claimed" || state === "Running" || state === "AwaitApproval";
458
+ }
459
+ function sameAgentRef(expected, actual) {
460
+ return actual?.agentId === expected.agentId && actual.profileRevision === expected.profileRevision;
461
+ }
462
+ function sameAgentEgressPayload(expected, actual) {
463
+ return sameAgentRef(expected.agentRef, actual.agentRef) && expected.sessionRef === actual.sessionRef && expected.policyRevision === actual.policyRevision && expected.eventId === actual.eventId && expected.cursor === actual.cursor && expected.contentHash === actual.contentHash && expected.byteCount === actual.byteCount && JSON.stringify(expected.payload) === JSON.stringify(actual.payload);
464
+ }
465
+ function matchesContentReadReceipt(request, receipt) {
466
+ return request.requestId === receipt.requestId && request.surface === receipt.surface && request.actor.kind === receipt.actor.kind && request.actor.id === receipt.actor.id && sameAgentRef(request.agentRef, receipt.agentRef) && request.sessionRef === receipt.sessionRef && request.runtime === receipt.runtime && request.cwd === receipt.cwd && request.policyRevision === receipt.policyRevision && request.target === receipt.target && request.mimeType === receipt.mimeType && request.decodeAs === receipt.decodeAs;
467
+ }
468
+ function contentReadCapability(surface) {
469
+ switch (surface) {
470
+ case "workspace":
471
+ return AGENT_CONTENT_WORKSPACE_READ_CAPABILITY;
472
+ case "transcript":
473
+ return AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY;
474
+ case "artifact":
475
+ return AGENT_CONTENT_ARTIFACT_READ_CAPABILITY;
337
476
  }
338
477
  }
339
- function buildHonoApp(deps) {
340
- const app = new Hono();
341
- const serverStartedAtMs = Date.now();
342
- if (deps.healthzRoute) {
343
- app.get("/healthz", (c) => c.json({ ok: true, uptimeMs: Date.now() - serverStartedAtMs }, 200));
478
+ var UnknownTaskError = class extends Error {
479
+ constructor(taskId) {
480
+ super(`unknown taskId: ${taskId}`);
481
+ this.taskId = taskId;
482
+ this.name = "UnknownTaskError";
344
483
  }
345
- app.post(BYOK_PAIR_PATH, async (c) => {
346
- const parsed = PairRequestSchema.safeParse(await readJsonBody(c));
347
- if (!parsed.success) {
348
- return c.json({ error: "pairingCode, deviceName, and devicePublicKey are required strings" }, 400);
349
- }
350
- const { pairingCode, deviceName, devicePublicKey } = parsed.data;
351
- let claims;
352
- try {
353
- claims = deps.pairing.redeemPairingCode(pairingCode);
354
- } catch (err) {
355
- if (err instanceof PairingCodeInvalidError) {
356
- return c.json({ error: err.message }, 401);
357
- }
358
- throw err;
359
- }
360
- const deviceId = generateDeviceId();
361
- deps.devices.register({
362
- tenantId: claims.tenantId,
363
- productId: claims.productId,
364
- deviceId,
365
- deviceName,
366
- devicePublicKey
367
- });
368
- const device = deps.devices.get(claims.tenantId, deviceId);
369
- if (device === void 0) {
370
- throw new Error("paired device row was not persisted");
371
- }
372
- const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
373
- deviceId: device.deviceId,
374
- tenantId: device.tenantId,
375
- productId: device.productId
376
- });
377
- const response = PairResponseSchema.parse({
378
- deviceId: device.deviceId,
379
- accessToken,
380
- refreshHint: expiresAt,
381
- tenantId: device.tenantId
484
+ taskId;
485
+ };
486
+ var TaskNotAwaitingApprovalError = class extends Error {
487
+ constructor(taskId, state, verb) {
488
+ super(`cannot ${verb} task ${taskId}: not awaiting approval (state ${state})`);
489
+ this.taskId = taskId;
490
+ this.state = state;
491
+ this.name = "TaskNotAwaitingApprovalError";
492
+ }
493
+ taskId;
494
+ state;
495
+ };
496
+ var StaleApprovalError = class extends Error {
497
+ constructor(taskId, requestedApprovalId, currentApprovalId) {
498
+ super(
499
+ `cannot resolve approval ${requestedApprovalId} for task ${taskId}: the currently pending approval is ${currentApprovalId ?? "(none recorded)"}`
500
+ );
501
+ this.taskId = taskId;
502
+ this.requestedApprovalId = requestedApprovalId;
503
+ this.currentApprovalId = currentApprovalId;
504
+ this.name = "StaleApprovalError";
505
+ }
506
+ taskId;
507
+ requestedApprovalId;
508
+ currentApprovalId;
509
+ };
510
+ function steerRejectionMessage(taskId, code, state, runtime) {
511
+ switch (code) {
512
+ case "task_terminal":
513
+ return `cannot steer task ${taskId}: task is already terminal (state ${state})`;
514
+ case "task_not_running":
515
+ return `cannot steer task ${taskId}: not running (state ${state})`;
516
+ case "steer_unsupported_runtime":
517
+ return `cannot steer task ${taskId}: claimed runtime ${runtime ?? "(unknown)"} does not support steering`;
518
+ }
519
+ }
520
+ var SteerRejectedError = class extends Error {
521
+ constructor(taskId, code, state, runtime) {
522
+ super(steerRejectionMessage(taskId, code, state, runtime));
523
+ this.taskId = taskId;
524
+ this.code = code;
525
+ this.state = state;
526
+ this.runtime = runtime;
527
+ this.name = "SteerRejectedError";
528
+ }
529
+ taskId;
530
+ code;
531
+ state;
532
+ runtime;
533
+ };
534
+ var AgentHomeProjectionCompletionError = class extends Error {
535
+ constructor(code, message) {
536
+ super(message);
537
+ this.code = code;
538
+ this.name = "AgentHomeProjectionCompletionError";
539
+ }
540
+ code;
541
+ };
542
+ var ConnectionHub = class {
543
+ constructor(taskStore, devices, taskLeaseMs, rateLimiter = new RateLimiter()) {
544
+ this.taskStore = taskStore;
545
+ this.devices = devices;
546
+ this.taskLeaseMs = taskLeaseMs;
547
+ this.rateLimiter = rateLimiter;
548
+ const sweepIntervalMs = Math.min(Math.max(taskLeaseMs, 10), MAX_LEASE_REAPER_SWEEP_INTERVAL_MS);
549
+ this.leaseReaperTimer = setInterval(() => this.sweepLeases(), sweepIntervalMs);
550
+ this.leaseReaperTimer.unref?.();
551
+ }
552
+ taskStore;
553
+ devices;
554
+ taskLeaseMs;
555
+ rateLimiter;
556
+ connections = /* @__PURE__ */ new Map();
557
+ outboxes = /* @__PURE__ */ new Map();
558
+ /** Idempotency window per device (N3) — recent inbound envelope ids, capped at {@link DEDUP_RING_CAPACITY}. */
559
+ dedupRings = /* @__PURE__ */ new Map();
560
+ longPollWaiters = /* @__PURE__ */ new Map();
561
+ runtimes = /* @__PURE__ */ new Map();
562
+ serverEvents = new AsyncEventQueue();
563
+ /** First-write-wins reliable facts; the reference composition's bounded in-memory readback. */
564
+ agentEgressReceipts = /* @__PURE__ */ new Map();
565
+ /** Accepted requests are the authority that later receipts/transfers must echo exactly. */
566
+ agentContentReadRequests = /* @__PURE__ */ new Map();
567
+ /** Content-free explicit-read audit facts keyed by exact authenticated device/request identity. */
568
+ agentContentReceipts = /* @__PURE__ */ new Map();
569
+ /** Immutable requested projection facts, keyed by authenticated device/request. */
570
+ agentHomeProjectionRequests = /* @__PURE__ */ new Map();
571
+ /** First terminal completion for each exact projection request. */
572
+ agentHomeProjectionCompletions = /* @__PURE__ */ new Map();
573
+ /**
574
+ * Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
575
+ * reaper's condition (c), see the "task-lease reaper" section below. Reset
576
+ * on every accepted inbound `task.*` envelope ({@link recordTaskActivity},
577
+ * called from {@link dispatchToHandler}); cleared once the task reaches a
578
+ * terminal state ({@link onStateChange}), so this map only ever holds
579
+ * entries for currently non-terminal claimed tasks.
580
+ */
581
+ taskActivity = /* @__PURE__ */ new Map();
582
+ /** The task-lease reaper's own periodic sweep timer — see the constructor and `sweepLeases` below. */
583
+ leaseReaperTimer;
584
+ /** {@link ConnectionHub.stats}'s `uptimeMs` origin — this hub's own construction instant. */
585
+ startedAtMs = Date.now();
586
+ /** {@link ConnectionHub.stats}'s `envelopesIn` — every {@link handleInbound} call, every outcome. */
587
+ envelopesInCount = 0;
588
+ /** {@link ConnectionHub.stats}'s `envelopesOut` — every envelope built via the single outbound choke point, {@link sendToDevice}. */
589
+ envelopesOutCount = 0;
590
+ /** {@link ConnectionHub.stats}'s `dedupDrops` (N3). */
591
+ dedupDropCount = 0;
592
+ /** {@link ConnectionHub.stats}'s `rateLimitEvents` — see {@link handleRateLimited}. */
593
+ rateLimitEventCount = 0;
594
+ /**
595
+ * M4 Phase 4 (gatekeeper LOW advisory): devices that have already had a
596
+ * `device.rate_limited` embedder event emitted for their CURRENT
597
+ * over-budget episode — see {@link handleRateLimited}'s own doc comment.
598
+ * Coalescing state only; {@link rateLimitEventCount} still counts every
599
+ * single hit regardless of what this suppresses.
600
+ */
601
+ rateLimitEventEmittedFor = /* @__PURE__ */ new Set();
602
+ /**
603
+ * Stop the task-lease reaper's sweep timer — called by `ByokServer.stop()`
604
+ * (`index.ts`) on shutdown. Idempotent: clearing an already-cleared
605
+ * interval is a safe no-op.
606
+ */
607
+ stopLeaseReaper() {
608
+ clearInterval(this.leaseReaperTimer);
609
+ }
610
+ /** The top-level `events` feed returned by `createByokServer` — see {@link ByokServerEvent}. */
611
+ subscribeServerEvents() {
612
+ return this.serverEvents.subscribe();
613
+ }
614
+ // ---------------------------------------------------------------------
615
+ // connection lifecycle — called from ws-server.ts / http.ts
616
+ // ---------------------------------------------------------------------
617
+ /**
618
+ * A daemon completed the WS handshake (`conn.hello`). Does not itself send
619
+ * `conn.ack` or redeliver — see {@link sendConnAck}/{@link redeliverAfterReconnect}.
620
+ *
621
+ * `capabilities` (M5, hello-capability plumbing): the daemon's own
622
+ * `conn.hello.capabilities` — previously silently ignored end to end (a
623
+ * verified gap: `ws-server.ts` forwarded only `runtimes`). Optional so
624
+ * every pre-M5 direct-construction call site (several tests construct a
625
+ * `ConnectionHub` and call this directly) keeps working unchanged; a
626
+ * connection this hub never learns capabilities for simply reads back
627
+ * `undefined` from {@link getDeviceCapabilities}.
628
+ */
629
+ registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets, clientVersion) {
630
+ const at = (/* @__PURE__ */ new Date()).toISOString();
631
+ this.connections.set(deviceId, {
632
+ ws,
633
+ connected: true,
634
+ lastSeen: at,
635
+ clientVersion,
636
+ runtimes,
637
+ capabilities,
638
+ configuredToolsets
382
639
  });
383
- return c.json(response, 200);
384
- });
385
- app.post(BYOK_CHALLENGE_PATH, async (c) => {
386
- const parsed = ChallengeRequestSchema.safeParse(await readJsonBody(c));
387
- if (!parsed.success) return c.json({ error: "deviceId is required" }, 400);
388
- const { deviceId } = parsed.data;
389
- const device = deps.devices.resolveByDeviceId(deviceId);
390
- if (!device || device.revoked) {
391
- return c.json({ error: "unknown or revoked device" }, 401);
392
- }
393
- const nonce = deps.nonces.issue(deviceId);
394
- const response = { nonce };
395
- return c.json(response, 200);
396
- });
397
- app.post(BYOK_TOKEN_PATH, async (c) => {
398
- const parsed = TokenRequestSchema.safeParse(await readJsonBody(c));
399
- if (!parsed.success) return c.json({ error: "deviceId, nonce, and signature are required" }, 400);
400
- const { deviceId, nonce, signature } = parsed.data;
401
- const device = deps.devices.resolveByDeviceId(deviceId);
402
- if (!device || device.revoked) {
403
- return c.json({ error: "unknown or revoked device" }, 401);
404
- }
405
- if (!deps.nonces.validate(deviceId, nonce)) {
406
- return c.json({ error: "invalid, expired, or already-used nonce" }, 401);
640
+ this.serverEvents.push({ kind: "device.connected", deviceId, at });
641
+ this.settleLongPollWaiter(deviceId);
642
+ }
643
+ sendConnAck(deviceId, capabilities) {
644
+ this.sendToDevice(
645
+ deviceId,
646
+ "conn.ack",
647
+ {
648
+ protocolVersion: PROTOCOL_VERSION,
649
+ capabilities,
650
+ serverTime: (/* @__PURE__ */ new Date()).toISOString()
651
+ },
652
+ {}
653
+ // conn.ack needs neither taskId nor sessionRef
654
+ );
655
+ }
656
+ /**
657
+ * Reconnection procedure step 3 (§9): redeliver, in `seq` order, every
658
+ * retained envelope with `seq > cursor` that still belongs to a
659
+ * non-terminal task. Called after `conn.ack` (step 2), per the spec.
660
+ */
661
+ redeliverAfterReconnect(deviceId, cursor) {
662
+ const conn = this.connections.get(deviceId);
663
+ if (!conn?.ws || !conn.connected) return;
664
+ for (const envelope of this.collectRelevant(deviceId, cursor)) {
665
+ conn.ws.send(encodeEnvelope(envelope));
407
666
  }
408
- if (!verifyNonceSignature(device.devicePublicKey, nonce, signature)) {
409
- return c.json({ error: "invalid signature" }, 401);
667
+ }
668
+ /**
669
+ * A device's WS socket closed. `ws` identifies *which* socket closed: if
670
+ * it's no longer the one this device's connection state points at (a
671
+ * newer WS reconnected, or long-poll took over — "last transport wins"),
672
+ * this close is for a stale/superseded socket and the device isn't
673
+ * actually gone, so the bookkeeping below is skipped entirely.
674
+ *
675
+ * M1 note: the M0 server force-failed/cancelled every in-flight task for a
676
+ * device the instant it disconnected, on the stated premise that "a task
677
+ * still in flight for a device that just disconnected can't be resumed, so
678
+ * it's terminated" — true only in the absence of a redelivery cursor. M1
679
+ * adds exactly that (§9): a task's in-flight state is retained
680
+ * independently of any one connection, specifically so it can survive a
681
+ * disconnect and resume via redelivery once the device reconnects. Failing
682
+ * tasks here would make that feature unreachable in practice (nothing
683
+ * would ever still be non-terminal by the time a reconnect happened), so
684
+ * this now only updates connection bookkeeping and leaves task state
685
+ * alone. A task left in-flight by a device that never reconnects stays
686
+ * that way until the SaaS embedder explicitly cancels it — no
687
+ * disconnect-timeout is specified by the protocol, so none is invented
688
+ * here (see the M1-2 report's contract-gap notes).
689
+ */
690
+ handleDisconnect(deviceId, ws) {
691
+ const conn = this.connections.get(deviceId);
692
+ if (!conn || conn.ws !== ws) return;
693
+ conn.connected = false;
694
+ conn.ws = void 0;
695
+ conn.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
696
+ conn.darkSince = Date.now();
697
+ this.serverEvents.push({ kind: "device.disconnected", deviceId, at: conn.lastSeen });
698
+ this.settleLongPollWaiter(deviceId);
699
+ }
700
+ // ---------------------------------------------------------------------
701
+ // long-poll fallback (§8) — GET /byok/events, called from http.ts
702
+ // ---------------------------------------------------------------------
703
+ /**
704
+ * Resolve immediately if there are already-relevant events past `cursor`;
705
+ * otherwise hold for up to `holdMs` and resolve with an empty result if
706
+ * nothing arrives. A device may be connected via WS or long-poll, not
707
+ * both simultaneously — a poll here supersedes (closes) any live WS for
708
+ * this device ("last one wins", documented at the type level on
709
+ * {@link ConnectionState}).
710
+ */
711
+ async pollEvents(deviceId, cursor, holdMs) {
712
+ this.takeOverAsLongPoll(deviceId);
713
+ this.settleLongPollWaiter(deviceId);
714
+ const immediate = this.collectRelevant(deviceId, cursor);
715
+ if (immediate.length > 0) {
716
+ return { events: immediate, cursor: this.currentCursor(deviceId) };
410
717
  }
411
- deps.nonces.markUsed(nonce);
412
- const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
413
- deviceId: device.deviceId,
414
- tenantId: device.tenantId,
415
- productId: device.productId
718
+ return new Promise((resolve) => {
719
+ const timer = setTimeout(() => {
720
+ this.longPollWaiters.delete(deviceId);
721
+ resolve({ events: [], cursor: this.currentCursor(deviceId) });
722
+ }, holdMs);
723
+ timer.unref?.();
724
+ this.longPollWaiters.set(deviceId, { cursor, resolve, timer });
416
725
  });
417
- const response = { accessToken, expiresAt };
418
- return c.json(response, 200);
419
- });
420
- app.post(BYOK_BLOBS_PATH, async (c) => {
421
- const principal = await authenticateBearer(c.req.header("authorization"), deps);
422
- if (!principal) return c.json({ error: "unauthorized" }, 401);
423
- const parsed = CreateBlobRequestSchema.safeParse(await readJsonBody(c));
424
- if (!parsed.success) return c.json({ error: "size, contentType, and contentHash are required" }, 400);
425
- if (parsed.data.size > deps.maxBlobSizeBytes) {
426
- return c.json({ error: `blob exceeds max size of ${deps.maxBlobSizeBytes} bytes` }, 413);
726
+ }
727
+ /** Make long-poll this device's active transport, closing any live WS ("last one wins", §8). */
728
+ takeOverAsLongPoll(deviceId) {
729
+ const conn = this.connections.get(deviceId);
730
+ const at = (/* @__PURE__ */ new Date()).toISOString();
731
+ const wasFreshlyConnected = !conn || conn.ws !== void 0 || !conn.connected;
732
+ if (conn?.ws) {
733
+ const ws = conn.ws;
734
+ this.connections.set(deviceId, {
735
+ connected: true,
736
+ lastSeen: at,
737
+ runtimes: conn.runtimes,
738
+ capabilities: conn.capabilities,
739
+ configuredToolsets: conn.configuredToolsets
740
+ });
741
+ ws.close(1e3, "superseded by long-poll connection");
742
+ } else if (!conn) {
743
+ this.connections.set(deviceId, { connected: true, lastSeen: at });
744
+ } else {
745
+ conn.connected = true;
746
+ conn.lastSeen = at;
747
+ conn.darkSince = void 0;
427
748
  }
428
- const reservationId = c.req.header("idempotency-key");
429
- if (!reservationId || reservationId.length > 200) {
430
- return c.json({ error: "Idempotency-Key header is required" }, 400);
749
+ if (wasFreshlyConnected) {
750
+ this.serverEvents.push({ kind: "device.connected", deviceId, at });
431
751
  }
432
- const blobId = reservationBlobId(principal.tenantId, reservationId);
433
- try {
434
- const created = await deps.blobStore.createUpload(parsed.data, blobId);
435
- const response = created;
436
- return c.json(response, 200);
437
- } catch (error) {
438
- if (error instanceof BlobDeclarationConflictError) {
439
- return c.json({ error: "storage_integrity_mismatch" }, 422);
752
+ }
753
+ /** Resolve (settle) any long-poll request currently held open for `deviceId`, if one exists. */
754
+ settleLongPollWaiter(deviceId) {
755
+ const waiter = this.longPollWaiters.get(deviceId);
756
+ if (!waiter) return;
757
+ this.longPollWaiters.delete(deviceId);
758
+ clearTimeout(waiter.timer);
759
+ waiter.resolve({ events: this.collectRelevant(deviceId, waiter.cursor), cursor: this.currentCursor(deviceId) });
760
+ }
761
+ // ---------------------------------------------------------------------
762
+ // inbound envelopes from a connected daemon
763
+ // ---------------------------------------------------------------------
764
+ /**
765
+ * Single inbound choke point for every daemon -> server envelope (N2/N3/
766
+ * P2) — called by both the WS path (`ws-server.ts`) and the long-poll send
767
+ * path (`POST /byok/messages`, `http.ts`) in place of reaching into
768
+ * per-type handlers directly. Runs a fixed gate, in order:
769
+ *
770
+ * 0. **rate limit (M4 Phase 4, part A)** — one token debited from this
771
+ * device's bucket ({@link rateLimiter}) for EVERY inbound envelope,
772
+ * before anything else runs (including the type-allow check below) —
773
+ * a flood of garbage-typed envelopes must cost the same budget as a
774
+ * flood of well-formed ones. Checked first specifically so an
775
+ * over-budget device is turned away as cheaply as possible, before any
776
+ * taskStore lookup or dedup bookkeeping. See {@link handleRateLimited}
777
+ * for what happens on exceed (never a silent drop).
778
+ * 1. **type-allow (P2)** — only {@link DAEMON_TO_SERVER_TYPES} may pass; a
779
+ * server -> daemon type arriving inbound is rejected before it's
780
+ * dispatched or counted accepted. `conn.hello` is the one non-task
781
+ * exception, and is accepted only from the bearer-authenticated
782
+ * long-poll route with an exact device/product/protocol match.
783
+ * 2. **ownership (N2)** — an envelope for a task already owned by a
784
+ * *different* device is dropped (logged), never force-failed:
785
+ * force-failing on an authz mismatch would let an attacker who merely
786
+ * guesses a `taskId` kill the real owner's task (a DoS). A task with no
787
+ * owner yet, or that doesn't exist at all, is not rejected here — the
788
+ * per-type handler's own no-op-on-missing-record behavior covers the
789
+ * latter.
790
+ * 3. **dedup (N3)** — an envelope `id` already seen from this device is a
791
+ * no-op: the wire is at-least-once (§9), this makes server-side
792
+ * processing at-most-once. Check-and-record is synchronous (Node is
793
+ * single-threaded), so it's atomic with respect to any other envelope
794
+ * for this device.
795
+ * 4. **dispatch** — handed to the existing per-type `on*` handler.
796
+ *
797
+ * Returns which outcome applied. A duplicate still counts as `accepted` on
798
+ * the `POST /byok/messages` wire (§8.2) — an idempotent replay is a
799
+ * wire-level success even though no handler ran a second time; only
800
+ * `rejected`/`rate_limited` (gate steps 0-2) are excluded from that count.
801
+ */
802
+ handleInbound(deviceId, envelope, authenticatedProductId) {
803
+ this.envelopesInCount++;
804
+ if (!this.rateLimiter.consume(deviceId)) {
805
+ this.handleRateLimited(deviceId);
806
+ return "rate_limited";
807
+ }
808
+ this.rateLimitEventEmittedFor.delete(deviceId);
809
+ if (envelope.type === "conn.hello") {
810
+ const payload = envelope.payload;
811
+ if (authenticatedProductId === void 0 || payload.deviceId !== deviceId || payload.productId !== authenticatedProductId || !payload.protocolVersions.includes(PROTOCOL_VERSION)) {
812
+ return "rejected";
440
813
  }
441
- throw error;
814
+ if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
815
+ this.dedupDropCount++;
816
+ return "duplicate";
817
+ }
818
+ this.registerLongPollHello(deviceId, payload);
819
+ return "accepted";
442
820
  }
443
- });
444
- app.post(BYOK_BLOB_FINALIZE_ROUTE, async (c) => {
445
- const principal = await authenticateBearer(c.req.header("authorization"), deps);
446
- if (!principal) return c.json({ error: "unauthorized" }, 401);
447
- const reservationId = c.req.header("idempotency-key");
448
- if (!reservationId || reservationId.length > 200) {
449
- return c.json({ error: "Idempotency-Key header is required" }, 400);
821
+ if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
822
+ return "rejected";
450
823
  }
451
- const blobId = c.req.param("id");
452
- if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
453
- return c.json({ error: "storage_integrity_mismatch" }, 422);
824
+ if (envelope.type === "agent.egress.reliable") {
825
+ return this.handleAgentEgressReliable(deviceId, envelope.payload);
454
826
  }
455
- if (!await deps.blobStore.exists(blobId)) {
456
- return c.json({ error: "storage_reservation_not_found" }, 404);
827
+ if (envelope.type === "agent.content.receipt") {
828
+ return this.handleAgentContentReceipt(deviceId, envelope.payload);
457
829
  }
458
- return c.body(null, 204);
459
- });
460
- app.get(BYOK_BLOB_URL_ROUTE, async (c) => {
461
- const principal = await authenticateBearer(c.req.header("authorization"), deps);
462
- if (!principal) return c.json({ error: "unauthorized" }, 401);
463
- const downloadUrl = await deps.blobStore.getDownloadUrl(c.req.param("id"));
464
- if (!downloadUrl) return c.json({ error: "blob not found" }, 404);
465
- const response = { downloadUrl };
466
- return c.json(response, 200);
467
- });
468
- app.put(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
469
- const blobId = c.req.param("id");
470
- const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
471
- if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "put", sig, exp)) {
472
- return c.json({ error: "invalid or expired signature" }, 401);
830
+ const taskId = envelope.task_id;
831
+ if (taskId === void 0) return "rejected";
832
+ const record = this.taskStore.get(taskId);
833
+ if (record && record.deviceId !== void 0 && record.deviceId !== deviceId) {
834
+ console.warn(`[byok/server] dropping ${envelope.type} for ${taskId}: owned by a different device`);
835
+ return "rejected";
473
836
  }
474
- const data = Buffer.from(await c.req.arrayBuffer());
475
- const result = await deps.blobStore.writeContent(blobId, data);
476
- if (!result.ok) return c.json({ error: result.reason }, 422);
477
- return c.body(null, 204);
478
- });
479
- app.get(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
480
- const blobId = c.req.param("id");
481
- const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
482
- if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "get", sig, exp)) {
483
- return c.json({ error: "invalid or expired signature" }, 401);
837
+ if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
838
+ this.dedupDropCount++;
839
+ return "duplicate";
484
840
  }
485
- const content = await deps.blobStore.readContent(blobId);
486
- if (!content) return c.json({ error: "blob not found" }, 404);
487
- return c.body(new Uint8Array(content.data), 200, { "content-type": content.contentType });
488
- });
489
- app.get(BYOK_EVENTS_PATH, async (c) => {
490
- const principal = await authenticateBearer(c.req.header("authorization"), deps);
491
- if (!principal) return c.json({ error: "unauthorized" }, 401);
492
- const cursorRaw = c.req.query("cursor");
493
- let cursor = 0;
494
- if (cursorRaw !== void 0) {
495
- const parsedCursor = Number(cursorRaw);
496
- if (!Number.isInteger(parsedCursor) || parsedCursor < 0) return c.json({ error: "invalid cursor" }, 400);
497
- cursor = parsedCursor;
841
+ this.dispatchToHandler(deviceId, taskId, envelope);
842
+ return "accepted";
843
+ }
844
+ /**
845
+ * Store before acking. Replays must agree on every identity/cursor/hash
846
+ * field and receive the original receipt id; a same event id with changed
847
+ * facts is rejected rather than treated as an update.
848
+ */
849
+ handleAgentEgressReliable(deviceId, payload) {
850
+ if (!this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
851
+ return "rejected";
852
+ }
853
+ const key = this.agentEgressReceiptKey(deviceId, payload.eventId);
854
+ const existing = this.agentEgressReceipts.get(key);
855
+ if (existing !== void 0) {
856
+ if (!sameAgentEgressPayload(existing.payload, payload)) return "rejected";
857
+ this.sendAgentEgressAck(deviceId, existing);
858
+ this.dedupDropCount++;
859
+ return "duplicate";
498
860
  }
499
- const result = await deps.hub.pollEvents(principal.deviceId, cursor, deps.longPollHoldMs);
500
- const response = { ...result, capabilities: [...CAPABILITY_FLAGS] };
501
- return c.json(response, 200);
502
- });
503
- app.post(BYOK_MESSAGES_PATH, async (c) => {
504
- const principal = await authenticateBearer(c.req.header("authorization"), deps);
505
- if (!principal) return c.json({ error: "unauthorized" }, 401);
506
- const parsed = MessagesSendRequestSchema.safeParse(await readJsonBody(c));
507
- if (!parsed.success) return c.json({ error: "messages must be an array of envelopes" }, 400);
508
- let accepted = 0;
509
- let rejected = 0;
510
- for (const envelope of parsed.data.messages) {
511
- const result = deps.hub.handleInbound(principal.deviceId, envelope, principal.productId);
512
- if (result === "rate_limited") {
513
- return c.json({ error: "rate limit exceeded" }, 429);
514
- }
515
- if (result === "rejected") rejected++;
516
- else accepted++;
861
+ const receipt = {
862
+ deviceId,
863
+ payload,
864
+ receiptId: crypto.randomUUID(),
865
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
866
+ };
867
+ this.agentEgressReceipts.set(key, receipt);
868
+ this.sendAgentEgressAck(deviceId, receipt);
869
+ return "accepted";
870
+ }
871
+ handleAgentContentReceipt(deviceId, payload) {
872
+ const capability = contentReadCapability(payload.surface);
873
+ if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) return "rejected";
874
+ if (payload.eventId !== payload.requestId) return "rejected";
875
+ const key = `${deviceId}\0${payload.requestId}`;
876
+ const request = this.agentContentReadRequests.get(key);
877
+ if (request === void 0 || !matchesContentReadReceipt(request, payload)) return "rejected";
878
+ const existing = this.agentContentReceipts.get(key);
879
+ if (existing !== void 0) {
880
+ if (JSON.stringify(existing.payload) !== JSON.stringify(payload)) return "rejected";
881
+ this.sendAgentContentReceiptAck(deviceId, existing);
882
+ this.dedupDropCount++;
883
+ return "duplicate";
517
884
  }
518
- const response = rejected > 0 ? { accepted, rejected } : { accepted };
519
- return c.json(response, 200);
520
- });
521
- return app;
522
- }
523
- function reservationBlobId(tenantId, reservationId) {
524
- return `blob_${createHash("sha256").update(`${tenantId}\0${reservationId}`).digest("hex")}`;
525
- }
526
- function signedUrlParams(sig, expRaw) {
527
- if (!sig || expRaw === void 0) return {};
528
- const exp = Number(expRaw);
529
- if (!Number.isFinite(exp)) return {};
530
- return { sig, exp };
531
- }
532
-
533
- // src/event-queue.ts
534
- var AsyncEventQueue = class {
535
- buffer = [];
536
- closed = false;
537
- waiters = [];
538
- push(value) {
539
- if (this.closed) return;
540
- this.buffer.push(value);
541
- this.wake();
885
+ const receipt = {
886
+ deviceId,
887
+ payload,
888
+ receiptId: payload.requestId,
889
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
890
+ };
891
+ this.agentContentReceipts.set(key, receipt);
892
+ this.sendAgentContentReceiptAck(deviceId, receipt);
893
+ return "accepted";
542
894
  }
543
- close() {
544
- if (this.closed) return;
545
- this.closed = true;
546
- this.wake();
895
+ sendAgentEgressAck(deviceId, receipt) {
896
+ this.sendToDevice(
897
+ deviceId,
898
+ "agent.egress.ack",
899
+ {
900
+ agentRef: receipt.payload.agentRef,
901
+ sessionRef: receipt.payload.sessionRef,
902
+ policyRevision: receipt.payload.policyRevision,
903
+ eventId: receipt.payload.eventId,
904
+ cursor: receipt.payload.cursor,
905
+ receiptId: receipt.receiptId
906
+ },
907
+ {}
908
+ );
547
909
  }
548
- wake() {
549
- const waiters = this.waiters;
550
- this.waiters = [];
551
- for (const resolve of waiters) resolve();
910
+ sendAgentContentReceiptAck(deviceId, receipt) {
911
+ this.sendToDevice(
912
+ deviceId,
913
+ "agent.egress.ack",
914
+ {
915
+ agentRef: receipt.payload.agentRef,
916
+ sessionRef: receipt.payload.sessionRef,
917
+ policyRevision: receipt.payload.policyRevision,
918
+ eventId: receipt.payload.eventId,
919
+ cursor: receipt.payload.cursor,
920
+ receiptId: receipt.receiptId
921
+ },
922
+ {}
923
+ );
552
924
  }
553
- waitForMore() {
554
- return new Promise((resolve) => this.waiters.push(resolve));
925
+ agentEgressReceiptKey(deviceId, eventId) {
926
+ return `${deviceId}\0${eventId}`;
555
927
  }
556
- /** Async-iterate the buffer from index 0, waiting for new pushes until closed. */
557
- subscribe() {
558
- const queue = this;
559
- return {
560
- [Symbol.asyncIterator]() {
561
- let index = 0;
562
- return {
563
- async next() {
564
- for (; ; ) {
565
- if (index < queue.buffer.length) {
566
- return { value: queue.buffer[index++], done: false };
567
- }
568
- if (queue.closed) {
569
- return { value: void 0, done: true };
570
- }
571
- await queue.waitForMore();
572
- }
573
- }
574
- };
575
- }
576
- };
928
+ /** Record the authenticated long-poll equivalent of the WS opening frame. */
929
+ registerLongPollHello(deviceId, payload) {
930
+ this.takeOverAsLongPoll(deviceId);
931
+ const connection = this.connections.get(deviceId);
932
+ if (!connection) return;
933
+ connection.runtimes = payload.runtimes;
934
+ connection.capabilities = payload.capabilities;
935
+ connection.configuredToolsets = payload.configuredToolsets;
936
+ connection.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
577
937
  }
578
- };
579
-
580
- // src/rate-limiter.ts
581
- var DEFAULT_MESSAGES_PER_SECOND = 50;
582
- var DEFAULT_BURST = 100;
583
- var DEFAULT_MAX_TRACKED_DEVICES = 1e4;
584
- var EVICTION_SWEEP_EVERY_N_CALLS = 1e3;
585
- var RateLimiter = class {
586
- messagesPerSecond;
587
- burst;
588
- buckets = /* @__PURE__ */ new Map();
589
938
  /**
590
- * Wall-clock idle duration (ms) after which a bucket is GUARANTEED to
591
- * already be refilled to `burst`, regardless of its actual token count at
592
- * last touch i.e. the time to go from 0 tokens to `burst` at this
593
- * instance's configured rate. `evictIdleBucketsIfDue` uses this as the
594
- * eviction threshold: dropping an entry idle at least this long and
595
- * recreating it fresh (tokens = burst) on the next `consume()` is
596
- * therefore behaviorally IDENTICAL to refilling it in place would have
597
- * been — both cap at `burst` — so eviction is semantically invisible to
598
- * the caller.
939
+ * M4 Phase 4 (part A): `deviceId` just exceeded its inbound-envelope rate
940
+ * limit. Never a silent drop: counts the occurrence
941
+ * ({@link rateLimitEventCount}, surfaced via {@link stats} every single
942
+ * hit, unconditionally) and, the FIRST time in this over-budget episode
943
+ * only, emits an embedder-facing `device.rate_limited`
944
+ * {@link ByokServerEvent} see that variant's own doc comment (`types.ts`)
945
+ * for the full per-transport enforcement shape.
946
+ *
947
+ * Gatekeeper LOW advisory (event amplification): a single flood can make
948
+ * `handleInbound` call this many times in a row — e.g. several WS frames
949
+ * already in flight before the close below actually lands, or a
950
+ * long-poll device retrying its `POST /byok/messages` before its bucket
951
+ * has refilled. Without coalescing, an embedder subscribed to
952
+ * `events.subscribe()` would see one `device.rate_limited` per hit, which
953
+ * is noisy for what is really ONE ongoing episode of one device
954
+ * flooding. `rateLimitEventEmittedFor` suppresses the repeats: this
955
+ * method only pushes the event the first time it sees a given `deviceId`
956
+ * since `handleInbound`'s own success path last cleared it (i.e. since
957
+ * this device was last confirmed back under budget) — the COUNTER above
958
+ * is entirely unaffected by this and still increments on every call,
959
+ * unconditionally.
960
+ *
961
+ * This method only handles the WS half of the enforcement shape (closing
962
+ * the live connection, if any, so the client's existing backoff+reconnect
963
+ * takes over — mirrors `takeOverAsLongPoll`'s own `ws.close`, the only
964
+ * other place this hub closes a device's socket directly); a long-poll
965
+ * device has no live `ws` to close here at all (`conn.ws` is `undefined`
966
+ * while long-polling — see {@link ConnectionState}), so `http.ts`'s
967
+ * `/byok/messages` handler maps this same `'rate_limited'` `handleInbound`
968
+ * outcome to an HTTP 429 for that transport instead.
599
969
  */
600
- idleEvictionThresholdMs;
601
- /** Finding R5: hard cap on `buckets.size` — see {@link DEFAULT_MAX_TRACKED_DEVICES}'s own doc comment. */
602
- maxTrackedDevices;
603
- /** Calls to `consume()` since the last sweep — see `EVICTION_SWEEP_EVERY_N_CALLS`. */
604
- callsSinceSweep = 0;
605
- constructor(opts = {}) {
606
- const messagesPerSecond = opts.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND;
607
- const burst = opts.burst ?? DEFAULT_BURST;
608
- const maxTrackedDevices = opts.maxTrackedDevices ?? DEFAULT_MAX_TRACKED_DEVICES;
609
- if (!Number.isFinite(messagesPerSecond) || messagesPerSecond <= 0) {
610
- throw new TypeError(`RateLimiter: messagesPerSecond must be a finite number > 0, got ${messagesPerSecond}`);
970
+ handleRateLimited(deviceId) {
971
+ this.rateLimitEventCount++;
972
+ if (!this.rateLimitEventEmittedFor.has(deviceId)) {
973
+ this.rateLimitEventEmittedFor.add(deviceId);
974
+ const at = (/* @__PURE__ */ new Date()).toISOString();
975
+ this.serverEvents.push({ kind: "device.rate_limited", deviceId, at });
611
976
  }
612
- if (!Number.isFinite(burst) || burst < 1) {
613
- throw new TypeError(`RateLimiter: burst must be a finite number >= 1, got ${burst}`);
977
+ const conn = this.connections.get(deviceId);
978
+ if (conn?.ws) {
979
+ conn.ws.close(1008, "rate limit exceeded");
980
+ }
981
+ }
982
+ /**
983
+ * Idempotency check-and-record (N3): `true` (duplicate) if `id` was
984
+ * already seen for `deviceId`; otherwise records it and returns `false`.
985
+ * Bounded to {@link DEDUP_RING_CAPACITY} ids per device — a ring, not an
986
+ * unbounded set — evicting the oldest once full.
987
+ */
988
+ checkAndRecordDuplicate(deviceId, id) {
989
+ let seen = this.dedupRings.get(deviceId);
990
+ if (!seen) {
991
+ seen = /* @__PURE__ */ new Set();
992
+ this.dedupRings.set(deviceId, seen);
993
+ }
994
+ if (seen.has(id)) return true;
995
+ seen.add(id);
996
+ if (seen.size > DEDUP_RING_CAPACITY) {
997
+ const oldest = seen.values().next().value;
998
+ if (oldest !== void 0) seen.delete(oldest);
999
+ }
1000
+ return false;
1001
+ }
1002
+ /**
1003
+ * Route one already-gated envelope (see {@link handleInbound}) to its
1004
+ * per-type handler. Type-allow/ownership/dedup have already run by the
1005
+ * time this executes, so the handlers below no longer need their own
1006
+ * device-mismatch checks — that authz decision now lives solely in
1007
+ * `handleInbound` (N2).
1008
+ *
1009
+ * Also the task-lease reaper's activity checkpoint
1010
+ * ({@link recordTaskActivity}): every envelope for a task that currently
1011
+ * *exists and is non-terminal* counts as proof of life for `taskId`'s
1012
+ * lease, regardless of what its per-type handler below ends up doing with
1013
+ * it (including a no-op/stale drop) — see the "task-lease reaper" section
1014
+ * further down for why. Deliberately gated on the record's existence and
1015
+ * non-terminal state *here*, before dispatch: `taskActivity` must never
1016
+ * gain an entry for a taskId that doesn't exist (a nonexistent/garbage id
1017
+ * an authenticated-but-malicious daemon could send indefinitely — an
1018
+ * unbounded-growth vector, since `taskId`s aren't deduped the way envelope
1019
+ * `id`s are) or for one that's already terminal (a stale/late message for
1020
+ * a finished task — `onStateChange` deletes the entry on the *real*
1021
+ * terminal transition, but a stale message arriving *after* that would
1022
+ * otherwise silently recreate it, since every per-type handler's own
1023
+ * terminal/unknown-task guard runs — and early-returns — only *after*
1024
+ * this would already have recorded activity).
1025
+ */
1026
+ dispatchToHandler(deviceId, taskId, envelope) {
1027
+ const record = this.taskStore.get(taskId);
1028
+ if (record && !isTerminal(record.state)) {
1029
+ this.recordTaskActivity(taskId);
614
1030
  }
615
- if (!Number.isFinite(maxTrackedDevices) || maxTrackedDevices < 1) {
616
- throw new TypeError(`RateLimiter: maxTrackedDevices must be a finite number >= 1, got ${maxTrackedDevices}`);
1031
+ switch (envelope.type) {
1032
+ case "task.claim":
1033
+ this.onClaim(deviceId, envelope.task_id, envelope.payload);
1034
+ return;
1035
+ case "task.started":
1036
+ this.onStarted(envelope.task_id, envelope.payload);
1037
+ return;
1038
+ case "task.decline":
1039
+ this.onDecline(deviceId, envelope.task_id, envelope.payload);
1040
+ return;
1041
+ case "task.progress":
1042
+ this.onProgress(envelope.task_id, envelope.payload);
1043
+ return;
1044
+ case "task.artifact":
1045
+ this.onArtifact(envelope.task_id, envelope.payload);
1046
+ return;
1047
+ case "task.await_approval":
1048
+ this.onAwaitApproval(envelope.task_id, envelope.payload);
1049
+ return;
1050
+ case "task.complete":
1051
+ this.onComplete(envelope.task_id, envelope.payload);
1052
+ return;
1053
+ case "task.fail":
1054
+ this.onFail(envelope.task_id, envelope.payload);
1055
+ return;
1056
+ case "task.cancelled":
1057
+ this.onCancelled(envelope.task_id, envelope.payload);
1058
+ return;
1059
+ case "task.approval_resolved":
1060
+ this.onApprovalResolved(envelope.task_id, envelope.payload);
1061
+ return;
1062
+ default:
1063
+ return;
617
1064
  }
618
- this.messagesPerSecond = messagesPerSecond;
619
- this.burst = burst;
620
- this.maxTrackedDevices = maxTrackedDevices;
621
- this.idleEvictionThresholdMs = burst / messagesPerSecond * 1e3;
1065
+ }
1066
+ /** Reset the task-lease reaper's per-task clock (condition (c) in the "task-lease reaper" section below). */
1067
+ recordTaskActivity(taskId) {
1068
+ this.taskActivity.set(taskId, Date.now());
622
1069
  }
623
1070
  /**
624
- * Debit one token from `key`'s bucket, refilling first for however much
625
- * wall-clock time has elapsed since its last refill. Returns `false`
626
- * (and debits nothing) when the bucket is currently empty the caller is
627
- * over budget right now.
1071
+ * Ownership (record.deviceId matching the connection's authenticated
1072
+ * deviceId) is enforced centrally by {@link handleInbound} (N2) before this
1073
+ * runs; only the idempotent-claim CAS and the first-claim device patch
1074
+ * happen here.
1075
+ *
1076
+ * M5 (claimed runtime, docs/protocol.md §3.1): `payload.runtime` — the
1077
+ * ACTUAL adapter the daemon selected (`TaskRunner.pickAdapter`,
1078
+ * `packages/client`'s `task-runner.ts`) — is recorded into
1079
+ * `TaskSnapshot.claimedRuntime` alongside the device patch, distinct from
1080
+ * the pre-existing `TaskSnapshot.runtime` (the merely REQUESTED runtime,
1081
+ * untouched here and set only once, at `dispatch()` time). Only ever
1082
+ * written on the FIRST real claim: the idempotent-CAS early return above
1083
+ * fires before this for a retried claim from a device that already owns
1084
+ * the task, so a redelivered/retried `task.claim` can never overwrite an
1085
+ * already-recorded `claimedRuntime` — including with a stale or absent
1086
+ * value from an out-of-order retry.
1087
+ *
1088
+ * S0/D-4 (claim-time capability snapshot): `payload.capabilities` — the
1089
+ * claiming adapter's OWN self-report, carried on this same `task.claim`
1090
+ * (docs/protocol.md §2.4) — supplies
1091
+ * `TaskSnapshot.claimedRuntimeCapabilities`, written in the same patch and
1092
+ * therefore under the same write-exactly-once property as `claimedRuntime`.
1093
+ *
1094
+ * Taken from the payload and from nowhere else. This hub deliberately does
1095
+ * NOT consult connection state (`conn.hello.runtimes[]`) for it — see
1096
+ * {@link SteerRejectedError} for why that source is structurally wrong for a
1097
+ * control decision, and that field's own doc comment (`types.ts`) for why
1098
+ * this is snapshotted rather than read live at steer time. A claim that
1099
+ * carries no `capabilities` (a pre-D-4 daemon) records `undefined`, which
1100
+ * the gate reads as "unknown" and refuses.
628
1101
  */
629
- consume(key) {
630
- const now = Date.now();
631
- this.evictIdleBucketsIfDue(now);
632
- let bucket = this.buckets.get(key);
633
- if (!bucket) {
634
- this.evictOldestIfAtCapacity();
635
- bucket = { tokens: this.burst, lastRefillMs: now };
636
- this.buckets.set(key, bucket);
637
- } else {
638
- const elapsedMs = now - bucket.lastRefillMs;
639
- if (elapsedMs > 0) {
640
- bucket.tokens = Math.min(this.burst, bucket.tokens + elapsedMs / 1e3 * this.messagesPerSecond);
641
- bucket.lastRefillMs = now;
642
- }
1102
+ onClaim(deviceId, taskId, payload) {
1103
+ const record = this.taskStore.get(taskId);
1104
+ if (!record) return;
1105
+ if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1106
+ console.warn(`[byok/server] dropping task.claim for ${taskId}: offered to a different device`);
1107
+ return;
643
1108
  }
644
- if (bucket.tokens < 1) return false;
645
- bucket.tokens -= 1;
646
- return true;
1109
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1110
+ this.forceFailOrDrop(taskId, "task.claim AgentRef does not exactly match the offered AgentRef");
1111
+ return;
1112
+ }
1113
+ if (record.state === "Claimed" || record.state === "Running") return;
1114
+ this.applyOrFail(taskId, "Claimed", {
1115
+ deviceId,
1116
+ claimedRuntime: payload.runtime,
1117
+ claimedRuntimeCapabilities: payload.capabilities
1118
+ });
647
1119
  }
648
1120
  /**
649
- * Every `EVICTION_SWEEP_EVERY_N_CALLS` calls to `consume()`, drops every
650
- * bucket idle for at least `idleEvictionThresholdMs` (see that field's doc
651
- * comment for why this is safe). Without this, `buckets` would hold one
652
- * permanent entry per historical key forever — every device that ever
653
- * connected, even long after it disconnected for good — growing without
654
- * bound over a long-lived server's lifetime.
1121
+ * `Claimed -> Running` (§3.1) a daemon actually starting the runtime
1122
+ * session, distinct from merely claiming. Ownership is already enforced
1123
+ * by {@link handleInbound} (N2) before this runs.
655
1124
  */
656
- evictIdleBucketsIfDue(now) {
657
- this.callsSinceSweep++;
658
- if (this.callsSinceSweep < EVICTION_SWEEP_EVERY_N_CALLS) return;
659
- this.callsSinceSweep = 0;
660
- for (const [key, bucket] of this.buckets) {
661
- if (now - bucket.lastRefillMs >= this.idleEvictionThresholdMs) {
662
- this.buckets.delete(key);
663
- }
664
- }
1125
+ onStarted(taskId, _payload) {
1126
+ const record = this.taskStore.get(taskId);
1127
+ if (!record) return;
1128
+ if (record.state === "Running") return;
1129
+ if (isTerminal(record.state)) return;
1130
+ this.applyOrFail(taskId, "Running", {});
665
1131
  }
666
1132
  /**
667
- * Finding R5 (cross-model re-review F10 residual): called right before
668
- * inserting a bucket for a genuinely NEW key, evicting the single
669
- * LEAST-RECENTLY-refilled entry if `buckets` is already at
670
- * `maxTrackedDevices` — an O(n) scan, but one that only ever runs once
671
- * the map is already at its hard ceiling (a rare/bounded event under
672
- * ordinary operation, not a per-call cost), mirroring this codebase's own
673
- * established "acceptable O(n) for a rare/bounded case" precedent (e.g.
674
- * `audit-log.ts`'s `compactPreservingLiveTasks` during rotation).
675
- *
676
- * Equivalence split (stated explicitly, not left implied):
677
- * - For any evicted bucket that was ALREADY idle for at least
678
- * `idleEvictionThresholdMs` (i.e. `evictIdleBucketsIfDue` would have
679
- * reclaimed it anyway, just not yet — sweeps only run every
680
- * `EVICTION_SWEEP_EVERY_N_CALLS` calls, not continuously), eviction is
681
- * PROVABLY equivalent to an in-place refill: both cap at `burst`, so a
682
- * caller can never observe the difference (see `idleEvictionThresholdMs`'s
683
- * own doc comment for the identical reasoning `evictIdleBucketsIfDue`
684
- * already relies on).
685
- * - For a bucket evicted EARLY — still within its idle threshold, forced
686
- * out only because `buckets` is at capacity (many thousands of
687
- * genuinely-distinct, actively-used keys, not a quiet one) — this is
688
- * BEST-EFFORT, not equivalence-preserving: whatever partial token debt
689
- * that key had is discarded, and its very next `consume()` call starts
690
- * completely fresh (`tokens: this.burst`), a strictly MORE permissive
691
- * outcome than if it had kept its place. This is an accepted,
692
- * deliberately bounded trade-off — it only ever engages under
693
- * cardinality far beyond any plausible real deployment — favoring
694
- * bounded memory over perfect per-key continuity in that one extreme
695
- * case.
1133
+ * `Offered -> Failed` (§3.2)a fail-closed pre-claim rejection. Only
1134
+ * ever legal from `Offered`; anything else is stale. Ownership is already
1135
+ * enforced by {@link handleInbound} (N2) before this runs.
696
1136
  */
697
- evictOldestIfAtCapacity() {
698
- if (this.buckets.size < this.maxTrackedDevices) return;
699
- let oldestKey;
700
- let oldestLastRefillMs = Infinity;
701
- for (const [key, bucket] of this.buckets) {
702
- if (bucket.lastRefillMs < oldestLastRefillMs) {
703
- oldestLastRefillMs = bucket.lastRefillMs;
704
- oldestKey = key;
705
- }
1137
+ onDecline(deviceId, taskId, payload) {
1138
+ const record = this.taskStore.get(taskId);
1139
+ if (!record) return;
1140
+ if (record.state !== "Offered") return;
1141
+ if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1142
+ console.warn(`[byok/server] dropping task.decline for ${taskId}: offered to a different device`);
1143
+ return;
706
1144
  }
707
- if (oldestKey !== void 0) this.buckets.delete(oldestKey);
708
- }
709
- };
710
-
711
- // src/hub.ts
712
- var DEFAULT_POLICY = { mode: "confirm" };
713
- var OUTBOX_RING_CAPACITY = 500;
714
- var DEDUP_RING_CAPACITY = 1024;
715
- var MAX_LEASE_REAPER_SWEEP_INTERVAL_MS = 3e4;
716
- function isTerminal(state) {
717
- return state === "Complete" || state === "Failed" || state === "Cancelled";
718
- }
719
- function isClaimedState(state) {
720
- return state === "Claimed" || state === "Running" || state === "AwaitApproval";
721
- }
722
- function sameAgentRef(expected, actual) {
723
- return actual?.agentId === expected.agentId && actual.profileRevision === expected.profileRevision;
724
- }
725
- function sameAgentEgressPayload(expected, actual) {
726
- return sameAgentRef(expected.agentRef, actual.agentRef) && expected.sessionRef === actual.sessionRef && expected.policyRevision === actual.policyRevision && expected.eventId === actual.eventId && expected.cursor === actual.cursor && expected.contentHash === actual.contentHash && expected.byteCount === actual.byteCount && JSON.stringify(expected.payload) === JSON.stringify(actual.payload);
727
- }
728
- function matchesContentReadReceipt(request, receipt) {
729
- return request.requestId === receipt.requestId && request.surface === receipt.surface && request.actor.kind === receipt.actor.kind && request.actor.id === receipt.actor.id && sameAgentRef(request.agentRef, receipt.agentRef) && request.sessionRef === receipt.sessionRef && request.runtime === receipt.runtime && request.cwd === receipt.cwd && request.policyRevision === receipt.policyRevision && request.target === receipt.target && request.mimeType === receipt.mimeType && request.decodeAs === receipt.decodeAs;
730
- }
731
- function contentReadCapability(surface) {
732
- switch (surface) {
733
- case "workspace":
734
- return AGENT_CONTENT_WORKSPACE_READ_CAPABILITY;
735
- case "transcript":
736
- return AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY;
737
- case "artifact":
738
- return AGENT_CONTENT_ARTIFACT_READ_CAPABILITY;
739
- }
740
- }
741
- var UnknownTaskError = class extends Error {
742
- constructor(taskId) {
743
- super(`unknown taskId: ${taskId}`);
744
- this.taskId = taskId;
745
- this.name = "UnknownTaskError";
1145
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1146
+ this.forceFailOrDrop(taskId, "task.decline AgentRef does not exactly match the offered AgentRef");
1147
+ return;
1148
+ }
1149
+ this.applyOrFail(taskId, "Failed", {
1150
+ result: { state: "Failed", reason: payload.reason, retryable: payload.retryable }
1151
+ });
746
1152
  }
747
- taskId;
748
- };
749
- var TaskNotAwaitingApprovalError = class extends Error {
750
- constructor(taskId, state, verb) {
751
- super(`cannot ${verb} task ${taskId}: not awaiting approval (state ${state})`);
752
- this.taskId = taskId;
753
- this.state = state;
754
- this.name = "TaskNotAwaitingApprovalError";
1153
+ onProgress(taskId, payload) {
1154
+ const record = this.taskStore.get(taskId);
1155
+ if (!record) return;
1156
+ const resumed = this.resumeIfImplicitlyApproved(record);
1157
+ if (resumed.state !== "Running") {
1158
+ this.forceFailOrDrop(taskId, "task.progress received while not Running");
1159
+ return;
1160
+ }
1161
+ const runtime = this.runtimes.get(taskId);
1162
+ if (!runtime) return;
1163
+ for (const event of payload.events) {
1164
+ runtime.queue.push({ kind: "agent", event });
1165
+ }
755
1166
  }
756
- taskId;
757
- state;
758
- };
759
- var StaleApprovalError = class extends Error {
760
- constructor(taskId, requestedApprovalId, currentApprovalId) {
761
- super(
762
- `cannot resolve approval ${requestedApprovalId} for task ${taskId}: the currently pending approval is ${currentApprovalId ?? "(none recorded)"}`
763
- );
764
- this.taskId = taskId;
765
- this.requestedApprovalId = requestedApprovalId;
766
- this.currentApprovalId = currentApprovalId;
767
- this.name = "StaleApprovalError";
1167
+ onArtifact(taskId, payload) {
1168
+ const record = this.taskStore.get(taskId);
1169
+ if (!record) return;
1170
+ const resumed = this.resumeIfImplicitlyApproved(record);
1171
+ if (resumed.state !== "Running") {
1172
+ this.forceFailOrDrop(taskId, "task.artifact received while not Running");
1173
+ return;
1174
+ }
1175
+ const runtime = this.runtimes.get(taskId);
1176
+ if (!runtime) return;
1177
+ runtime.queue.push({ kind: "artifact", artifact: payload });
768
1178
  }
769
- taskId;
770
- requestedApprovalId;
771
- currentApprovalId;
772
- };
773
- function steerRejectionMessage(taskId, code, state, runtime) {
774
- switch (code) {
775
- case "task_terminal":
776
- return `cannot steer task ${taskId}: task is already terminal (state ${state})`;
777
- case "task_not_running":
778
- return `cannot steer task ${taskId}: not running (state ${state})`;
779
- case "steer_unsupported_runtime":
780
- return `cannot steer task ${taskId}: claimed runtime ${runtime ?? "(unknown)"} does not support steering`;
1179
+ onAwaitApproval(taskId, payload) {
1180
+ const record = this.taskStore.get(taskId);
1181
+ if (!record) return;
1182
+ if (record.state === "AwaitApproval") {
1183
+ if (payload.approvalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1184
+ this.taskStore.setPendingApprovalId?.(taskId, payload.approvalId);
1185
+ this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
1186
+ }
1187
+ return;
1188
+ }
1189
+ this.applyOrFail(taskId, "AwaitApproval", { pendingApprovalId: payload.approvalId });
1190
+ const after = this.taskStore.get(taskId);
1191
+ if (after?.state !== "AwaitApproval") return;
1192
+ this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
781
1193
  }
782
- }
783
- var SteerRejectedError = class extends Error {
784
- constructor(taskId, code, state, runtime) {
785
- super(steerRejectionMessage(taskId, code, state, runtime));
786
- this.taskId = taskId;
787
- this.code = code;
788
- this.state = state;
789
- this.runtime = runtime;
790
- this.name = "SteerRejectedError";
1194
+ onComplete(taskId, payload) {
1195
+ const record = this.taskStore.get(taskId);
1196
+ if (!record) return;
1197
+ if (isTerminal(record.state)) return;
1198
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1199
+ this.forceFailOrDrop(taskId, "task.complete AgentRef does not exactly match the offered AgentRef");
1200
+ return;
1201
+ }
1202
+ this.resumeIfImplicitlyApproved(record);
1203
+ const result = {
1204
+ state: "Complete",
1205
+ summary: payload.summary,
1206
+ sessionRef: payload.sessionRef,
1207
+ artifactRefs: payload.artifactRefs,
1208
+ // additive-minor (`task.complete.document`): projected verbatim, the
1209
+ // same way `summary`/`artifactRefs` are. Nothing to validate or
1210
+ // measure here — the payload only got this far because
1211
+ // `TaskCompletePayloadSchema`'s own refinement already enforced
1212
+ // JSON-serializability and `RESULT_DOCUMENT_MAX_BYTES` at the inbound
1213
+ // boundary, and re-checking would make this a second authority for a
1214
+ // rule the wire already owns. Stays `undefined` for the two cases that
1215
+ // never carry one: a daemon with no extractor configured, and a
1216
+ // pre-`result-document` daemon build.
1217
+ document: payload.document
1218
+ };
1219
+ this.applyOrFail(taskId, "Complete", { result, sessionRef: payload.sessionRef });
791
1220
  }
792
- taskId;
793
- code;
794
- state;
795
- runtime;
796
- };
797
- var ConnectionHub = class {
798
- constructor(taskStore, devices, taskLeaseMs, rateLimiter = new RateLimiter()) {
799
- this.taskStore = taskStore;
800
- this.devices = devices;
801
- this.taskLeaseMs = taskLeaseMs;
802
- this.rateLimiter = rateLimiter;
803
- const sweepIntervalMs = Math.min(Math.max(taskLeaseMs, 10), MAX_LEASE_REAPER_SWEEP_INTERVAL_MS);
804
- this.leaseReaperTimer = setInterval(() => this.sweepLeases(), sweepIntervalMs);
805
- this.leaseReaperTimer.unref?.();
1221
+ onFail(taskId, payload) {
1222
+ const record = this.taskStore.get(taskId);
1223
+ if (!record) return;
1224
+ if (isTerminal(record.state)) return;
1225
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1226
+ this.forceFailOrDrop(taskId, "task.fail AgentRef does not exactly match the offered AgentRef");
1227
+ return;
1228
+ }
1229
+ const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
1230
+ this.applyOrFail(taskId, "Failed", { result });
806
1231
  }
807
- taskStore;
808
- devices;
809
- taskLeaseMs;
810
- rateLimiter;
811
- connections = /* @__PURE__ */ new Map();
812
- outboxes = /* @__PURE__ */ new Map();
813
- /** Idempotency window per device (N3) — recent inbound envelope ids, capped at {@link DEDUP_RING_CAPACITY}. */
814
- dedupRings = /* @__PURE__ */ new Map();
815
- longPollWaiters = /* @__PURE__ */ new Map();
816
- runtimes = /* @__PURE__ */ new Map();
817
- serverEvents = new AsyncEventQueue();
818
- /** First-write-wins reliable facts; the reference composition's bounded in-memory readback. */
819
- agentEgressReceipts = /* @__PURE__ */ new Map();
820
- /** Accepted requests are the authority that later receipts/transfers must echo exactly. */
821
- agentContentReadRequests = /* @__PURE__ */ new Map();
822
- /** Content-free explicit-read audit facts keyed by exact authenticated device/request identity. */
823
- agentContentReceipts = /* @__PURE__ */ new Map();
824
- /**
825
- * Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
826
- * reaper's condition (c), see the "task-lease reaper" section below. Reset
827
- * on every accepted inbound `task.*` envelope ({@link recordTaskActivity},
828
- * called from {@link dispatchToHandler}); cleared once the task reaches a
829
- * terminal state ({@link onStateChange}), so this map only ever holds
830
- * entries for currently non-terminal claimed tasks.
831
- */
832
- taskActivity = /* @__PURE__ */ new Map();
833
- /** The task-lease reaper's own periodic sweep timer — see the constructor and `sweepLeases` below. */
834
- leaseReaperTimer;
835
- /** {@link ConnectionHub.stats}'s `uptimeMs` origin — this hub's own construction instant. */
836
- startedAtMs = Date.now();
837
- /** {@link ConnectionHub.stats}'s `envelopesIn` — every {@link handleInbound} call, every outcome. */
838
- envelopesInCount = 0;
839
- /** {@link ConnectionHub.stats}'s `envelopesOut` — every envelope built via the single outbound choke point, {@link sendToDevice}. */
840
- envelopesOutCount = 0;
841
- /** {@link ConnectionHub.stats}'s `dedupDrops` (N3). */
842
- dedupDropCount = 0;
843
- /** {@link ConnectionHub.stats}'s `rateLimitEvents` — see {@link handleRateLimited}. */
844
- rateLimitEventCount = 0;
845
1232
  /**
846
- * M4 Phase 4 (gatekeeper LOW advisory): devices that have already had a
847
- * `device.rate_limited` embedder event emitted for their CURRENT
848
- * over-budget episode see {@link handleRateLimited}'s own doc comment.
849
- * Coalescing state only; {@link rateLimitEventCount} still counts every
850
- * single hit regardless of what this suppresses.
1233
+ * Dual-purpose on receipt (§3.3): if the server already moved this task to
1234
+ * `Cancelled` on its own action (the common case — `cancelTask()` is
1235
+ * authoritative immediately, §4), this is a late idempotent ack — silent,
1236
+ * not a warning (this is the other half of the M0 gatekeeper finding this
1237
+ * change resolves). Otherwise it's the authoritative trigger for a
1238
+ * cancellation the daemon observed that the server didn't initiate.
1239
+ * Ownership is already enforced by {@link handleInbound} (N2) before this
1240
+ * runs.
851
1241
  */
852
- rateLimitEventEmittedFor = /* @__PURE__ */ new Set();
1242
+ onCancelled(taskId, payload) {
1243
+ const record = this.taskStore.get(taskId);
1244
+ if (!record) return;
1245
+ if (record.state === "Cancelled") return;
1246
+ if (isTerminal(record.state)) return;
1247
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1248
+ this.forceFailOrDrop(taskId, "task.cancelled AgentRef does not exactly match the offered AgentRef");
1249
+ return;
1250
+ }
1251
+ this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
1252
+ }
853
1253
  /**
854
- * Stop the task-lease reaper's sweep timer — called by `ByokServer.stop()`
855
- * (`index.ts`) on shutdown. Idempotent: clearing an already-cleared
856
- * interval is a safe no-op.
1254
+ * M4 (additive-minor, `task.approval_resolved`): the EXPLICIT counterpart
1255
+ * to {@link resumeIfImplicitlyApproved} a daemon that resolved a pending
1256
+ * approval entirely LOCALLY now reports it immediately, instead of the
1257
+ * server only finding out after the fact once evidence (a later
1258
+ * `task.progress`/`task.artifact`/`task.complete`) proves it.
1259
+ *
1260
+ * Relationship to the implicit path (both stay, permanently — this is not
1261
+ * a replacement): {@link resumeIfImplicitlyApproved} remains completely
1262
+ * untouched as the fallback for (a) an old daemon that predates this
1263
+ * message, and (b) a daemon connected to an old server that never
1264
+ * advertised the `approval_resolved` capability flag (`version.ts`) at
1265
+ * handshake time — in either case the daemon never sends this message at
1266
+ * all (see `packages/client`'s `task-runner.ts`), and the server keeps
1267
+ * inferring the resolution from evidence exactly as it did before this
1268
+ * message existed. When THIS message does arrive first, it already moves
1269
+ * the record out of `AwaitApproval` (see below) — so by the time any
1270
+ * following `task.progress`/etc. reaches `onProgress`/`onArtifact`/
1271
+ * `onComplete`, `resumeIfImplicitlyApproved`'s own `record.state !==
1272
+ * 'AwaitApproval'` guard is already true and it no-ops, never firing its
1273
+ * own `task.approval_resolved_implicit` event a second time for the same
1274
+ * resolution. The two mechanisms race harmlessly: whichever one the
1275
+ * server processes first is the one that actually performs the
1276
+ * transition; the other is naturally inert once it runs.
1277
+ *
1278
+ * Three outcomes, mirroring this file's existing per-type idempotency
1279
+ * conventions:
1280
+ * - `AwaitApproval` (the expected case): legal transition to `Running`
1281
+ * (an existing `TASK_TRANSITIONS` edge, the same one `approveTask`
1282
+ * itself uses) plus a `task.approval_resolved` {@link ByokServerEvent}
1283
+ * carrying `approvalId`/`decision`/`resolvedBy` for an embedder to
1284
+ * observe.
1285
+ * - Already `Running` (evidence — or the implicit path — already beat
1286
+ * this message to it): idempotent no-op, silent, mirroring
1287
+ * `onStarted`'s own already-running guard.
1288
+ * - Terminal, or a state that was never `AwaitApproval` in the first
1289
+ * place (`Offered`/`Claimed` — a genuinely out-of-sequence report):
1290
+ * stale no-op with a `console.warn`, matching this file's existing
1291
+ * stale-message convention (`forceFailOrDrop`, `handleInbound`'s
1292
+ * ownership-mismatch drop) — never force-failed, since a late/
1293
+ * redelivered report about a task that has already moved on is not
1294
+ * evidence of anything currently wrong with it.
1295
+ *
1296
+ * This is also the residual-race resolution the accompanying protocol/docs
1297
+ * update documents: a SaaS decision (`approveTask`/`rejectTask`) already in
1298
+ * flight when the local resolution happens can still land on the server
1299
+ * FIRST and move the record to a terminal state before this message
1300
+ * arrives — in that case this message hits the terminal branch above and
1301
+ * is a stale no-op, exactly like any other late message for an
1302
+ * already-terminal task. The window for that crossing is now
1303
+ * network-latency-sized (how long this message takes to arrive), not
1304
+ * "until the next progress message" the way the pre-existing implicit-only
1305
+ * inference left it.
857
1306
  */
858
- stopLeaseReaper() {
859
- clearInterval(this.leaseReaperTimer);
860
- }
861
- /** The top-level `events` feed returned by `createByokServer` — see {@link ByokServerEvent}. */
862
- subscribeServerEvents() {
863
- return this.serverEvents.subscribe();
1307
+ onApprovalResolved(taskId, payload) {
1308
+ const record = this.taskStore.get(taskId);
1309
+ if (!record) return;
1310
+ if (record.state === "Running") return;
1311
+ if (record.state !== "AwaitApproval") {
1312
+ console.warn(
1313
+ `[byok/server] dropping task.approval_resolved for ${taskId}: not awaiting approval (state ${record.state})`
1314
+ );
1315
+ return;
1316
+ }
1317
+ if (payload.approvalId !== void 0 && record.pendingApprovalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1318
+ console.warn(
1319
+ `[byok/server] stale task.approval_resolved for ${taskId}: reported approvalId ${payload.approvalId} does not match the currently pending ${record.pendingApprovalId}`
1320
+ );
1321
+ return;
1322
+ }
1323
+ this.applyOrFail(taskId, "Running", {});
1324
+ const after = this.taskStore.get(taskId);
1325
+ if (after?.state !== "Running") return;
1326
+ const targeted = record.deviceId !== void 0 && (this.getDeviceCapabilities(record.deviceId)?.includes("approval-targeting") ?? false);
1327
+ this.serverEvents.push({
1328
+ kind: "task.approval_resolved",
1329
+ taskId,
1330
+ approvalId: payload.approvalId,
1331
+ decision: payload.decision,
1332
+ resolvedBy: payload.resolvedBy,
1333
+ at: after.updatedAt,
1334
+ targeted
1335
+ });
864
1336
  }
865
1337
  // ---------------------------------------------------------------------
866
- // connection lifecyclecalled from ws-server.ts / http.ts
1338
+ // transition helpersthe single place "illegal transition" is handled
867
1339
  // ---------------------------------------------------------------------
868
1340
  /**
869
- * A daemon completed the WS handshake (`conn.hello`). Does not itself send
870
- * `conn.ack` or redeliver see {@link sendConnAck}/{@link redeliverAfterReconnect}.
1341
+ * M5 (approval targeting): single low-level wrapper around
1342
+ * `TaskStore.transition` that every ACTUAL state-changing write in this
1343
+ * file goes through — {@link applyOrFail}'s legal-transition branch,
1344
+ * {@link forceFailOrDrop}, and {@link resumeIfImplicitlyApproved} (the one
1345
+ * caller that transitions WITHOUT going through `applyOrFail` at all).
1346
+ * Two responsibilities, folded in here once rather than duplicated at
1347
+ * each call site:
871
1348
  *
872
- * `capabilities` (M5, hello-capability plumbing): the daemon's own
873
- * `conn.hello.capabilities` previously silently ignored end to end (a
874
- * verified gap: `ws-server.ts` forwarded only `runtimes`). Optional so
875
- * every pre-M5 direct-construction call site (several tests construct a
876
- * `ConnectionHub` and call this directly) keeps working unchanged; a
877
- * connection this hub never learns capabilities for simply reads back
878
- * `undefined` from {@link getDeviceCapabilities}.
1349
+ * 1. Clears `pendingApprovalId` whenever `record` is LEAVING
1350
+ * `AwaitApproval` (`record.state === 'AwaitApproval' && to !==
1351
+ * 'AwaitApproval'`) the id this hub last recorded for a task's
1352
+ * pending approval ({@link onAwaitApproval}) is meaningless the
1353
+ * instant that task is no longer awaiting it. Clearing it here,
1354
+ * centrally, is what guarantees a FUTURE `AwaitApproval` cycle for
1355
+ * the SAME task always starts from a clean slate instead of silently
1356
+ * inheriting a stale id from a previous cycle (which would make a
1357
+ * stale-approval check against the NEW cycle's real pending id
1358
+ * spuriously pass just because a leftover value happened to still be
1359
+ * sitting in the record).
1360
+ * 2. Calls {@link onStateChange} — every call site already did this
1361
+ * immediately after its own `transition` call; folding it in here
1362
+ * removes the duplication and the chance of a future call site
1363
+ * forgetting it.
879
1364
  */
880
- registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets, clientVersion) {
881
- const at = (/* @__PURE__ */ new Date()).toISOString();
882
- this.connections.set(deviceId, {
883
- ws,
884
- connected: true,
885
- lastSeen: at,
886
- clientVersion,
887
- runtimes,
888
- capabilities,
889
- configuredToolsets
890
- });
891
- this.serverEvents.push({ kind: "device.connected", deviceId, at });
892
- this.settleLongPollWaiter(deviceId);
893
- }
894
- sendConnAck(deviceId, capabilities) {
895
- this.sendToDevice(
896
- deviceId,
897
- "conn.ack",
898
- {
899
- protocolVersion: PROTOCOL_VERSION,
900
- capabilities,
901
- serverTime: (/* @__PURE__ */ new Date()).toISOString()
902
- },
903
- {}
904
- // conn.ack needs neither taskId nor sessionRef
905
- );
1365
+ transitionTask(taskId, record, to, patch) {
1366
+ const finalPatch = record.state === "AwaitApproval" && to !== "AwaitApproval" ? { ...patch, pendingApprovalId: void 0 } : patch;
1367
+ const updated = this.taskStore.transition(taskId, to, finalPatch);
1368
+ this.onStateChange(updated);
1369
+ return updated;
906
1370
  }
907
1371
  /**
908
- * Reconnection procedure step 3 (§9): redeliver, in `seq` order, every
909
- * retained envelope with `seq > cursor` that still belongs to a
910
- * non-terminal task. Called after `conn.ack` (step 2), per the spec.
1372
+ * Apply `taskId`'s state -> `target`. If that's illegal per
1373
+ * `TASK_TRANSITIONS`, fall back to `Failed` (if reachable from the current
1374
+ * state); this is the "illegal transition = error + task.fail path" rule.
911
1375
  */
912
- redeliverAfterReconnect(deviceId, cursor) {
913
- const conn = this.connections.get(deviceId);
914
- if (!conn?.ws || !conn.connected) return;
915
- for (const envelope of this.collectRelevant(deviceId, cursor)) {
916
- conn.ws.send(encodeEnvelope(envelope));
1376
+ applyOrFail(taskId, target, patch) {
1377
+ const record = this.taskStore.get(taskId);
1378
+ if (!record) return;
1379
+ if (canTransition(record.state, target)) {
1380
+ this.transitionTask(taskId, record, target, patch);
1381
+ return;
917
1382
  }
1383
+ this.forceFailOrDrop(taskId, `illegal transition ${record.state} -> ${target}`);
918
1384
  }
919
1385
  /**
920
- * A device's WS socket closed. `ws` identifies *which* socket closed: if
921
- * it's no longer the one this device's connection state points at (a
922
- * newer WS reconnected, or long-poll took over "last transport wins"),
923
- * this close is for a stale/superseded socket and the device isn't
924
- * actually gone, so the bookkeeping below is skipped entirely.
1386
+ * M4 Phase 3 hardening (orchestrator-directed fix for the server-state-
1387
+ * machine trace finding): a task can be resolved entirely OUT-OF-BAND, on
1388
+ * the daemon side only (M4 Phase 3's local `approvals.resolve`
1389
+ * control-socket path, `packages/client`) the server never sees a wire
1390
+ * `task.approve`/`task.reject` for it, so its own record sits in
1391
+ * `AwaitApproval` even though the daemon already resumed and moved on.
925
1392
  *
926
- * M1 note: the M0 server force-failed/cancelled every in-flight task for a
927
- * device the instant it disconnected, on the stated premise that "a task
928
- * still in flight for a device that just disconnected can't be resumed, so
929
- * it's terminated" true only in the absence of a redelivery cursor. M1
930
- * adds exactly that (§9): a task's in-flight state is retained
931
- * independently of any one connection, specifically so it can survive a
932
- * disconnect and resume via redelivery once the device reconnects. Failing
933
- * tasks here would make that feature unreachable in practice (nothing
934
- * would ever still be non-terminal by the time a reconnect happened), so
935
- * this now only updates connection bookkeeping and leaves task state
936
- * alone. A task left in-flight by a device that never reconnects stays
937
- * that way until the SaaS embedder explicitly cancels it — no
938
- * disconnect-timeout is specified by the protocol, so none is invented
939
- * here (see the M1-2 report's contract-gap notes).
940
- */
941
- handleDisconnect(deviceId, ws) {
942
- const conn = this.connections.get(deviceId);
943
- if (!conn || conn.ws !== ws) return;
944
- conn.connected = false;
945
- conn.ws = void 0;
946
- conn.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
947
- conn.darkSince = Date.now();
948
- this.serverEvents.push({ kind: "device.disconnected", deviceId, at: conn.lastSeen });
949
- this.settleLongPollWaiter(deviceId);
950
- }
951
- // ---------------------------------------------------------------------
952
- // long-poll fallback (§8) — GET /byok/events, called from http.ts
953
- // ---------------------------------------------------------------------
954
- /**
955
- * Resolve immediately if there are already-relevant events past `cursor`;
956
- * otherwise hold for up to `holdMs` and resolve with an empty result if
957
- * nothing arrives. A device may be connected via WS or long-poll, not
958
- * both simultaneously — a poll here supersedes (closes) any live WS for
959
- * this device ("last one wins", documented at the type level on
960
- * {@link ConnectionState}).
961
- */
962
- async pollEvents(deviceId, cursor, holdMs) {
963
- this.takeOverAsLongPoll(deviceId);
964
- this.settleLongPollWaiter(deviceId);
965
- const immediate = this.collectRelevant(deviceId, cursor);
966
- if (immediate.length > 0) {
967
- return { events: immediate, cursor: this.currentCursor(deviceId) };
968
- }
969
- return new Promise((resolve) => {
970
- const timer = setTimeout(() => {
971
- this.longPollWaiters.delete(deviceId);
972
- resolve({ events: [], cursor: this.currentCursor(deviceId) });
973
- }, holdMs);
974
- timer.unref?.();
975
- this.longPollWaiters.set(deviceId, { cursor, resolve, timer });
976
- });
977
- }
978
- /** Make long-poll this device's active transport, closing any live WS ("last one wins", §8). */
979
- takeOverAsLongPoll(deviceId) {
980
- const conn = this.connections.get(deviceId);
981
- const at = (/* @__PURE__ */ new Date()).toISOString();
982
- const wasFreshlyConnected = !conn || conn.ws !== void 0 || !conn.connected;
983
- if (conn?.ws) {
984
- const ws = conn.ws;
985
- this.connections.set(deviceId, {
986
- connected: true,
987
- lastSeen: at,
988
- runtimes: conn.runtimes,
989
- capabilities: conn.capabilities,
990
- configuredToolsets: conn.configuredToolsets
991
- });
992
- ws.close(1e3, "superseded by long-poll connection");
993
- } else if (!conn) {
994
- this.connections.set(deviceId, { connected: true, lastSeen: at });
995
- } else {
996
- conn.connected = true;
997
- conn.lastSeen = at;
998
- conn.darkSince = void 0;
999
- }
1000
- if (wasFreshlyConnected) {
1001
- this.serverEvents.push({ kind: "device.connected", deviceId, at });
1002
- }
1003
- }
1004
- /** Resolve (settle) any long-poll request currently held open for `deviceId`, if one exists. */
1005
- settleLongPollWaiter(deviceId) {
1006
- const waiter = this.longPollWaiters.get(deviceId);
1007
- if (!waiter) return;
1008
- this.longPollWaiters.delete(deviceId);
1009
- clearTimeout(waiter.timer);
1010
- waiter.resolve({ events: this.collectRelevant(deviceId, waiter.cursor), cursor: this.currentCursor(deviceId) });
1011
- }
1012
- // ---------------------------------------------------------------------
1013
- // inbound envelopes from a connected daemon
1014
- // ---------------------------------------------------------------------
1015
- /**
1016
- * Single inbound choke point for every daemon -> server envelope (N2/N3/
1017
- * P2) — called by both the WS path (`ws-server.ts`) and the long-poll send
1018
- * path (`POST /byok/messages`, `http.ts`) in place of reaching into
1019
- * per-type handlers directly. Runs a fixed gate, in order:
1393
+ * The daemon is the execution authority in this security model (the SaaS
1394
+ * only ever *proposes* see docs/spec.md); the daemon sending ANY further
1395
+ * task.* traffic for a task the server still thinks is `AwaitApproval` is
1396
+ * itself sufficient proof the approval was resolved locally, one way or
1397
+ * another. Rather than force-failing/dropping that traffic (the pre-fix
1398
+ * behavior `onProgress`/`onArtifact`'s own `!== 'Running'` guard,
1399
+ * `onComplete`'s illegal-transition fallback), this applies the exact same
1400
+ * `AwaitApproval -> Running` edge `approveTask` already uses (a
1401
+ * pre-existing legal `TASK_TRANSITIONS` edge, not a new one) through the
1402
+ * normal transition path `taskStore.transition` + `onStateChange`, same
1403
+ * as `applyOrFail`'s own legal-transition branch so every existing
1404
+ * consumer of task state (§, `TaskHandle.events()`, the lease reaper's
1405
+ * `taskActivity`) observes it exactly as it would a real wire
1406
+ * `task.approve`. Then emits `task.approval_resolved_implicit` (a
1407
+ * `ByokServerEvent`, NOT a wire message — see that type's own doc comment)
1408
+ * so an embedder can distinguish this from an operator-driven approval.
1020
1409
  *
1021
- * 0. **rate limit (M4 Phase 4, part A)** one token debited from this
1022
- * device's bucket ({@link rateLimiter}) for EVERY inbound envelope,
1023
- * before anything else runs (including the type-allow check below)
1024
- * a flood of garbage-typed envelopes must cost the same budget as a
1025
- * flood of well-formed ones. Checked first specifically so an
1026
- * over-budget device is turned away as cheaply as possible, before any
1027
- * taskStore lookup or dedup bookkeeping. See {@link handleRateLimited}
1028
- * for what happens on exceed (never a silent drop).
1029
- * 1. **type-allow (P2)**only {@link DAEMON_TO_SERVER_TYPES} may pass; a
1030
- * server -> daemon type arriving inbound is rejected before it's
1031
- * dispatched or counted accepted. `conn.hello` is the one non-task
1032
- * exception, and is accepted only from the bearer-authenticated
1033
- * long-poll route with an exact device/product/protocol match.
1034
- * 2. **ownership (N2)** — an envelope for a task already owned by a
1035
- * *different* device is dropped (logged), never force-failed:
1036
- * force-failing on an authz mismatch would let an attacker who merely
1037
- * guesses a `taskId` kill the real owner's task (a DoS). A task with no
1038
- * owner yet, or that doesn't exist at all, is not rejected here — the
1039
- * per-type handler's own no-op-on-missing-record behavior covers the
1040
- * latter.
1041
- * 3. **dedup (N3)** — an envelope `id` already seen from this device is a
1042
- * no-op: the wire is at-least-once (§9), this makes server-side
1043
- * processing at-most-once. Check-and-record is synchronous (Node is
1044
- * single-threaded), so it's atomic with respect to any other envelope
1045
- * for this device.
1046
- * 4. **dispatch** — handed to the existing per-type `on*` handler.
1410
+ * M4 (additive-minor, superseding this method's own former "deferred"
1411
+ * framing): a first-class `task.approval_resolved` WIRE notification now
1412
+ * exists (`onApprovalResolved`, below) a daemon that supports it, talking
1413
+ * to a server that advertised the `approval_resolved` capability flag
1414
+ * (`version.ts`), reports a local resolution explicitly and immediately
1415
+ * instead of leaving the server to infer it here. This method is
1416
+ * UNTOUCHED and remains the permanent fallback for the N/N-1 cases where
1417
+ * that explicit report never arrives (an old daemon, or an old server this
1418
+ * daemon is talking to) — see `onApprovalResolved`'s own doc comment for
1419
+ * the full relationship between the two paths, including why they can
1420
+ * never both fire for the same resolution.
1047
1421
  *
1048
- * Returns which outcome applied. A duplicate still counts as `accepted` on
1049
- * the `POST /byok/messages` wire (§8.2) an idempotent replay is a
1050
- * wire-level success even though no handler ran a second time; only
1051
- * `rejected`/`rate_limited` (gate steps 0-2) are excluded from that count.
1422
+ * No-op (returns `record` unchanged) for any state other than
1423
+ * `AwaitApproval` every other guard (terminal, pre-claim, already-
1424
+ * Running) keeps exactly its current behavior. `onFail`/`onCancelled`
1425
+ * never call this: `Failed`/`Cancelled` are already direct, legal edges
1426
+ * from `AwaitApproval`, so they never hit the illegal-transition path this
1427
+ * exists to avoid in the first place.
1052
1428
  */
1053
- handleInbound(deviceId, envelope, authenticatedProductId) {
1054
- this.envelopesInCount++;
1055
- if (!this.rateLimiter.consume(deviceId)) {
1056
- this.handleRateLimited(deviceId);
1057
- return "rate_limited";
1058
- }
1059
- this.rateLimitEventEmittedFor.delete(deviceId);
1060
- if (envelope.type === "conn.hello") {
1061
- const payload = envelope.payload;
1062
- if (authenticatedProductId === void 0 || payload.deviceId !== deviceId || payload.productId !== authenticatedProductId || !payload.protocolVersions.includes(PROTOCOL_VERSION)) {
1063
- return "rejected";
1064
- }
1065
- if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
1066
- this.dedupDropCount++;
1067
- return "duplicate";
1068
- }
1069
- this.registerLongPollHello(deviceId, payload);
1070
- return "accepted";
1071
- }
1072
- if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
1073
- return "rejected";
1074
- }
1075
- if (envelope.type === "agent.egress.reliable") {
1076
- return this.handleAgentEgressReliable(deviceId, envelope.payload);
1077
- }
1078
- if (envelope.type === "agent.content.receipt") {
1079
- return this.handleAgentContentReceipt(deviceId, envelope.payload);
1080
- }
1081
- const taskId = envelope.task_id;
1082
- if (taskId === void 0) return "rejected";
1083
- const record = this.taskStore.get(taskId);
1084
- if (record && record.deviceId !== void 0 && record.deviceId !== deviceId) {
1085
- console.warn(`[byok/server] dropping ${envelope.type} for ${taskId}: owned by a different device`);
1086
- return "rejected";
1087
- }
1088
- if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
1089
- this.dedupDropCount++;
1090
- return "duplicate";
1091
- }
1092
- this.dispatchToHandler(deviceId, taskId, envelope);
1093
- return "accepted";
1429
+ resumeIfImplicitlyApproved(record) {
1430
+ if (record.state !== "AwaitApproval") return record;
1431
+ const updated = this.transitionTask(record.taskId, record, "Running", {});
1432
+ this.serverEvents.push({ kind: "task.approval_resolved_implicit", taskId: record.taskId, at: updated.updatedAt });
1433
+ return updated;
1094
1434
  }
1095
1435
  /**
1096
- * Store before acking. Replays must agree on every identity/cursor/hash
1097
- * field and receive the original receipt id; a same event id with changed
1098
- * facts is rejected rather than treated as an update.
1436
+ * A daemon message didn't fit the task's current state (e.g. progress
1437
+ * while AwaitApproval). Force the task to `Failed` if that's reachable;
1438
+ * otherwise it's already terminal (or `Offered`, which has no Failed edge)
1439
+ * and there's nothing safe to do but log + drop.
1099
1440
  */
1100
- handleAgentEgressReliable(deviceId, payload) {
1101
- if (!this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1102
- return "rejected";
1103
- }
1104
- const key = this.agentEgressReceiptKey(deviceId, payload.eventId);
1105
- const existing = this.agentEgressReceipts.get(key);
1106
- if (existing !== void 0) {
1107
- if (!sameAgentEgressPayload(existing.payload, payload)) return "rejected";
1108
- this.sendAgentEgressAck(deviceId, existing);
1109
- this.dedupDropCount++;
1110
- return "duplicate";
1441
+ forceFailOrDrop(taskId, reason) {
1442
+ const record = this.taskStore.get(taskId);
1443
+ if (!record) return;
1444
+ if (canTransition(record.state, "Failed")) {
1445
+ this.transitionTask(taskId, record, "Failed", {
1446
+ result: { state: "Failed", reason, retryable: false }
1447
+ });
1448
+ return;
1111
1449
  }
1112
- const receipt = {
1113
- deviceId,
1114
- payload,
1115
- receiptId: crypto.randomUUID(),
1116
- recordedAt: (/* @__PURE__ */ new Date()).toISOString()
1117
- };
1118
- this.agentEgressReceipts.set(key, receipt);
1119
- this.sendAgentEgressAck(deviceId, receipt);
1120
- return "accepted";
1450
+ console.warn(`[byok/server] dropping message for ${taskId} (state ${record.state}): ${reason}`);
1121
1451
  }
1122
- handleAgentContentReceipt(deviceId, payload) {
1123
- const capability = contentReadCapability(payload.surface);
1124
- if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) return "rejected";
1125
- if (payload.eventId !== payload.requestId) return "rejected";
1126
- const key = `${deviceId}\0${payload.requestId}`;
1127
- const request = this.agentContentReadRequests.get(key);
1128
- if (request === void 0 || !matchesContentReadReceipt(request, payload)) return "rejected";
1129
- const existing = this.agentContentReceipts.get(key);
1130
- if (existing !== void 0) {
1131
- if (JSON.stringify(existing.payload) !== JSON.stringify(payload)) return "rejected";
1132
- this.sendAgentContentReceiptAck(deviceId, existing);
1133
- this.dedupDropCount++;
1134
- return "duplicate";
1452
+ onStateChange(record) {
1453
+ this.serverEvents.push({
1454
+ kind: "task.state",
1455
+ taskId: record.taskId,
1456
+ state: record.state,
1457
+ at: record.updatedAt,
1458
+ // M5 (claimed runtime): mirrors the snapshot's own field verbatim
1459
+ // see ByokServerEvent's 'task.state' variant doc comment (types.ts).
1460
+ claimedRuntime: record.claimedRuntime
1461
+ });
1462
+ if (isTerminal(record.state)) {
1463
+ this.taskActivity.delete(record.taskId);
1464
+ }
1465
+ const runtime = this.runtimes.get(record.taskId);
1466
+ if (!runtime) return;
1467
+ runtime.queue.push({ kind: "state", state: record.state, at: record.updatedAt });
1468
+ if (isTerminal(record.state)) {
1469
+ runtime.resolveResult(record.result ?? { state: record.state });
1470
+ runtime.queue.close();
1135
1471
  }
1136
- const receipt = {
1137
- deviceId,
1138
- payload,
1139
- receiptId: payload.requestId,
1140
- recordedAt: (/* @__PURE__ */ new Date()).toISOString()
1141
- };
1142
- this.agentContentReceipts.set(key, receipt);
1143
- this.sendAgentContentReceiptAck(deviceId, receipt);
1144
- return "accepted";
1145
- }
1146
- sendAgentEgressAck(deviceId, receipt) {
1147
- this.sendToDevice(
1148
- deviceId,
1149
- "agent.egress.ack",
1150
- {
1151
- agentRef: receipt.payload.agentRef,
1152
- sessionRef: receipt.payload.sessionRef,
1153
- policyRevision: receipt.payload.policyRevision,
1154
- eventId: receipt.payload.eventId,
1155
- cursor: receipt.payload.cursor,
1156
- receiptId: receipt.receiptId
1157
- },
1158
- {}
1159
- );
1160
- }
1161
- sendAgentContentReceiptAck(deviceId, receipt) {
1162
- this.sendToDevice(
1163
- deviceId,
1164
- "agent.egress.ack",
1165
- {
1166
- agentRef: receipt.payload.agentRef,
1167
- sessionRef: receipt.payload.sessionRef,
1168
- policyRevision: receipt.payload.policyRevision,
1169
- eventId: receipt.payload.eventId,
1170
- cursor: receipt.payload.cursor,
1171
- receiptId: receipt.receiptId
1172
- },
1173
- {}
1174
- );
1175
- }
1176
- agentEgressReceiptKey(deviceId, eventId) {
1177
- return `${deviceId}\0${eventId}`;
1178
- }
1179
- /** Record the authenticated long-poll equivalent of the WS opening frame. */
1180
- registerLongPollHello(deviceId, payload) {
1181
- this.takeOverAsLongPoll(deviceId);
1182
- const connection = this.connections.get(deviceId);
1183
- if (!connection) return;
1184
- connection.runtimes = payload.runtimes;
1185
- connection.capabilities = payload.capabilities;
1186
- connection.configuredToolsets = payload.configuredToolsets;
1187
- connection.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
1188
1472
  }
1473
+ // ---------------------------------------------------------------------
1474
+ // task-lease reaper (Decision: Failed(retryable:true) on dark-device
1475
+ // timeout — no new task state, no new wire message)
1476
+ // ---------------------------------------------------------------------
1189
1477
  /**
1190
- * M4 Phase 4 (part A): `deviceId` just exceeded its inbound-envelope rate
1191
- * limit. Never a silent drop: counts the occurrence
1192
- * ({@link rateLimitEventCount}, surfaced via {@link stats} every single
1193
- * hit, unconditionally) and, the FIRST time in this over-budget episode
1194
- * only, emits an embedder-facing `device.rate_limited`
1195
- * {@link ByokServerEvent} see that variant's own doc comment (`types.ts`)
1196
- * for the full per-transport enforcement shape.
1478
+ * Task lease: a backstop for a device that goes dark mid-task and never
1479
+ * comes back distinct from, and layered on top of, M1's redelivery
1480
+ * (docs/protocol.md §9), which already handles "device reconnects within
1481
+ * the window, nothing lost." Decision (user+design): reuse the existing
1482
+ * `Failed` terminal state and its `retryable` flag —
1483
+ * `Failed(retryable: true, reason: 'lease-expired')` exactly like any
1484
+ * other `task.fail`. The embedder is expected to treat this exactly like
1485
+ * any other retryable failure: re-dispatch as a brand-new task.
1197
1486
  *
1198
- * Gatekeeper LOW advisory (event amplification): a single flood can make
1199
- * `handleInbound` call this many times in a row e.g. several WS frames
1200
- * already in flight before the close below actually lands, or a
1201
- * long-poll device retrying its `POST /byok/messages` before its bucket
1202
- * has refilled. Without coalescing, an embedder subscribed to
1203
- * `events.subscribe()` would see one `device.rate_limited` per hit, which
1204
- * is noisy for what is really ONE ongoing episode of one device
1205
- * flooding. `rateLimitEventEmittedFor` suppresses the repeats: this
1206
- * method only pushes the event the first time it sees a given `deviceId`
1207
- * since `handleInbound`'s own success path last cleared it (i.e. since
1208
- * this device was last confirmed back under budget) — the COUNTER above
1209
- * is entirely unaffected by this and still increments on every call,
1210
- * unconditionally.
1487
+ * Implemented as a periodic sweep (see the constructor), not a per-task
1488
+ * timer, so a device that goes dark *after* being idle-but-connected for a
1489
+ * while is still caught on a later tick without needing extra bookkeeping
1490
+ * at disconnect time. `sweepLeases` reaps a task only when ALL of the
1491
+ * following hold, checked fresh on every tick (never cached):
1492
+ *
1493
+ * (a) the task is in a non-terminal *claimed* state `Claimed`,
1494
+ * `Running`, or `AwaitApproval` ({@link isClaimedState}). `Offered`
1495
+ * is excluded: it has no owning device yet, so there's nothing to
1496
+ * be "dark".
1497
+ * (b) the owning device is dark right now ({@link deviceDarkSince}
1498
+ * returns a timestamp rather than `undefined`) disconnected
1499
+ * outright, or (long-poll only) hasn't been seen since before the
1500
+ * lease window. A live WS connection is never dark from the
1501
+ * reaper's point of view: `heartbeat.ts` already independently
1502
+ * proves liveness at the transport level and flips
1503
+ * `connected: false` via `handleDisconnect` once it stops getting
1504
+ * pongs — the reaper just reads that flag rather than re-deriving
1505
+ * it. `deviceDarkSince` also returns *when* darkness started
1506
+ * ({@link ConnectionState.darkSince}, set the instant
1507
+ * `handleDisconnect` flips the connection dark) — that instant
1508
+ * feeds condition (c), below.
1509
+ * (c) a full `taskLeaseMs` has elapsed since the *later* of: the task's
1510
+ * own last inbound-activity timestamp ({@link taskActivity}, reset
1511
+ * in {@link dispatchToHandler} on every accepted envelope for a
1512
+ * known, non-terminal task — claim, started, progress, artifact,
1513
+ * await_approval, anything), and (b)'s dark-since instant. Taking
1514
+ * the *later* of the two — not the activity timestamp alone — is
1515
+ * what makes a device going dark start a fresh, full countdown
1516
+ * instead of reusing whatever (possibly already-stale) activity
1517
+ * timestamp the task happened to have: a task can be legitimately
1518
+ * idle *while connected* for longer than `taskLeaseMs` (a long turn
1519
+ * with no progress events, or just a quiet stretch) without being
1520
+ * touched — see (b) — but the instant such a task's device
1521
+ * disconnects, that stale activity timestamp must NOT immediately
1522
+ * satisfy (c) on its own, or the task would get reaped within one
1523
+ * sweep tick of disconnect instead of waiting the full window. That
1524
+ * was a real bug (a disconnect-after-long-idle reap effectively
1525
+ * indistinguishable from the M0 disconnect-alone-fails-the-task
1526
+ * behavior M1 removed, below); anchoring (c) to
1527
+ * `max(lastActivity, darkSince)` fixes it — idle time that elapsed
1528
+ * *before* the device went dark no longer counts toward the lease,
1529
+ * only silence *after* dark-start does.
1530
+ *
1531
+ * (b) and (c) are deliberately independent clocks, not one merged check.
1532
+ * The property this most exists to protect: a *connected*, momentarily
1533
+ * idle device mid-turn must never be reaped, no matter how long
1534
+ * `taskLeaseMs` is — condition (b) alone blocks that regardless of (c).
1535
+ * This is also what keeps this from reintroducing the M0 bug M1
1536
+ * deliberately removed (see `handleDisconnect`'s own doc comment above) —
1537
+ * M0 force-failed a task the instant its device disconnected; M1
1538
+ * correctly stopped doing that so a task could survive a disconnect and
1539
+ * resume via redelivery. This reaper does not revert that: disconnect
1540
+ * ALONE still does nothing here either — (c) still has to independently
1541
+ * hold, and per the `max(...)` above it only will once a full
1542
+ * `taskLeaseMs` has genuinely elapsed *since the device went dark*, no
1543
+ * matter how stale the task's own activity timestamp already was at that
1544
+ * moment.
1211
1545
  *
1212
- * This method only handles the WS half of the enforcement shape (closing
1213
- * the live connection, if any, so the client's existing backoff+reconnect
1214
- * takes over mirrors `takeOverAsLongPoll`'s own `ws.close`, the only
1215
- * other place this hub closes a device's socket directly); a long-poll
1216
- * device has no live `ws` to close here at all (`conn.ws` is `undefined`
1217
- * while long-polling see {@link ConnectionState}), so `http.ts`'s
1218
- * `/byok/messages` handler maps this same `'rate_limited'` `handleInbound`
1219
- * outcome to an HTTP 429 for that transport instead.
1546
+ * Interaction with redelivery (§9): redelivery is what handles "the
1547
+ * device came back within the window" nothing to reap, normal traffic
1548
+ * resumes. This reaper is what handles "it never came back." Idempotent
1549
+ * claim (`onClaim`'s CAS) still protects server-side bookkeeping if a
1550
+ * device wakes up *after* its task was already reaped and retries a stale
1551
+ * claim/progress/etc. for it: every per-type handler's existing
1552
+ * stale/terminal-task guard (§9) drops it as a no-op, same as any other
1553
+ * late message for an already-terminal task no new guard was needed for
1554
+ * that here.
1555
+ *
1556
+ * Accepted residual (by design, not a bug): idempotent claim protects
1557
+ * *server-side* state, not the device's own local side effects. A dark
1558
+ * device that wakes up after its task has already been reaped may still
1559
+ * be mid-way through running real local work (file writes, shell
1560
+ * commands, whatever the runtime adapter was doing) for a task the server
1561
+ * has since moved on from — and that the embedder may have already
1562
+ * re-dispatched elsewhere. There is no way to remotely guarantee a
1563
+ * truly-dark device stops running; the mitigation is entirely
1564
+ * `taskLeaseMs` being set far larger than any realistic task duration, so
1565
+ * this can only happen to a device that was genuinely gone for a very
1566
+ * long time, not a normal slow turn.
1220
1567
  */
1221
- handleRateLimited(deviceId) {
1222
- this.rateLimitEventCount++;
1223
- if (!this.rateLimitEventEmittedFor.has(deviceId)) {
1224
- this.rateLimitEventEmittedFor.add(deviceId);
1225
- const at = (/* @__PURE__ */ new Date()).toISOString();
1226
- this.serverEvents.push({ kind: "device.rate_limited", deviceId, at });
1227
- }
1228
- const conn = this.connections.get(deviceId);
1229
- if (conn?.ws) {
1230
- conn.ws.close(1008, "rate limit exceeded");
1568
+ sweepLeases() {
1569
+ const now = Date.now();
1570
+ for (const record of this.taskStore.list()) {
1571
+ if (!isClaimedState(record.state) || !record.deviceId) continue;
1572
+ const darkSince = this.deviceDarkSince(record.deviceId, now);
1573
+ if (darkSince === void 0) continue;
1574
+ const lastActivity = this.taskActivity.get(record.taskId) ?? Date.parse(record.updatedAt);
1575
+ const silentSince = Math.max(lastActivity, darkSince);
1576
+ if (now - silentSince < this.taskLeaseMs) continue;
1577
+ this.reapTask(record.taskId);
1231
1578
  }
1232
1579
  }
1233
1580
  /**
1234
- * Idempotency check-and-record (N3): `true` (duplicate) if `id` was
1235
- * already seen for `deviceId`; otherwise records it and returns `false`.
1236
- * Bounded to {@link DEDUP_RING_CAPACITY} ids per device a ring, not an
1237
- * unbounded set evicting the oldest once full.
1581
+ * Condition (b) above: `undefined` while `deviceId`'s connection counts as
1582
+ * alive (never reapable, no matter how stale (c) is); otherwise the
1583
+ * epoch-ms instant it began counting as "dark" for lease purposes.
1584
+ * `sweepLeases` combines this with (c)'s own last-activity instant via
1585
+ * `max(...)` so the full `taskLeaseMs` silence window is always measured
1586
+ * from whichever of the two happened later.
1238
1587
  */
1239
- checkAndRecordDuplicate(deviceId, id) {
1240
- let seen = this.dedupRings.get(deviceId);
1241
- if (!seen) {
1242
- seen = /* @__PURE__ */ new Set();
1243
- this.dedupRings.set(deviceId, seen);
1244
- }
1245
- if (seen.has(id)) return true;
1246
- seen.add(id);
1247
- if (seen.size > DEDUP_RING_CAPACITY) {
1248
- const oldest = seen.values().next().value;
1249
- if (oldest !== void 0) seen.delete(oldest);
1588
+ deviceDarkSince(deviceId, nowMs) {
1589
+ const conn = this.connections.get(deviceId);
1590
+ if (!conn || !conn.connected) {
1591
+ return conn?.darkSince ?? 0;
1250
1592
  }
1251
- return false;
1593
+ if (conn.ws) return void 0;
1594
+ const lastSeenMs = Date.parse(conn.lastSeen);
1595
+ return nowMs - lastSeenMs >= this.taskLeaseMs ? lastSeenMs : void 0;
1252
1596
  }
1253
- /**
1254
- * Route one already-gated envelope (see {@link handleInbound}) to its
1255
- * per-type handler. Type-allow/ownership/dedup have already run by the
1256
- * time this executes, so the handlers below no longer need their own
1257
- * device-mismatch checks — that authz decision now lives solely in
1258
- * `handleInbound` (N2).
1259
- *
1260
- * Also the task-lease reaper's activity checkpoint
1261
- * ({@link recordTaskActivity}): every envelope for a task that currently
1262
- * *exists and is non-terminal* counts as proof of life for `taskId`'s
1263
- * lease, regardless of what its per-type handler below ends up doing with
1264
- * it (including a no-op/stale drop) — see the "task-lease reaper" section
1265
- * further down for why. Deliberately gated on the record's existence and
1266
- * non-terminal state *here*, before dispatch: `taskActivity` must never
1267
- * gain an entry for a taskId that doesn't exist (a nonexistent/garbage id
1268
- * an authenticated-but-malicious daemon could send indefinitely — an
1269
- * unbounded-growth vector, since `taskId`s aren't deduped the way envelope
1270
- * `id`s are) or for one that's already terminal (a stale/late message for
1271
- * a finished task — `onStateChange` deletes the entry on the *real*
1272
- * terminal transition, but a stale message arriving *after* that would
1273
- * otherwise silently recreate it, since every per-type handler's own
1274
- * terminal/unknown-task guard runs — and early-returns — only *after*
1275
- * this would already have recorded activity).
1276
- */
1277
- dispatchToHandler(deviceId, taskId, envelope) {
1597
+ /** Reap one lease-expired task through the exact same TaskStore/canTransition path — and terminal-event emission — as any other `task.fail` (see {@link applyOrFail}). */
1598
+ reapTask(taskId) {
1278
1599
  const record = this.taskStore.get(taskId);
1279
- if (record && !isTerminal(record.state)) {
1280
- this.recordTaskActivity(taskId);
1281
- }
1282
- switch (envelope.type) {
1283
- case "task.claim":
1284
- this.onClaim(deviceId, envelope.task_id, envelope.payload);
1285
- return;
1286
- case "task.started":
1287
- this.onStarted(envelope.task_id, envelope.payload);
1288
- return;
1289
- case "task.decline":
1290
- this.onDecline(deviceId, envelope.task_id, envelope.payload);
1291
- return;
1292
- case "task.progress":
1293
- this.onProgress(envelope.task_id, envelope.payload);
1294
- return;
1295
- case "task.artifact":
1296
- this.onArtifact(envelope.task_id, envelope.payload);
1297
- return;
1298
- case "task.await_approval":
1299
- this.onAwaitApproval(envelope.task_id, envelope.payload);
1300
- return;
1301
- case "task.complete":
1302
- this.onComplete(envelope.task_id, envelope.payload);
1303
- return;
1304
- case "task.fail":
1305
- this.onFail(envelope.task_id, envelope.payload);
1306
- return;
1307
- case "task.cancelled":
1308
- this.onCancelled(envelope.task_id, envelope.payload);
1309
- return;
1310
- case "task.approval_resolved":
1311
- this.onApprovalResolved(envelope.task_id, envelope.payload);
1312
- return;
1313
- default:
1314
- return;
1600
+ if (!record || isTerminal(record.state)) return;
1601
+ this.applyOrFail(taskId, "Failed", {
1602
+ result: { state: "Failed", reason: "lease-expired", retryable: true }
1603
+ });
1604
+ }
1605
+ // ---------------------------------------------------------------------
1606
+ // dispatch() and the TaskHandle it returns
1607
+ // ---------------------------------------------------------------------
1608
+ async dispatch(input) {
1609
+ if (input.egressPolicy !== void 0 && input.sessionRef === void 0) {
1610
+ throw new Error("Agent egress resume dispatch requires an exact sessionRef; use dispatchFreshAgentEgress for fresh execution");
1315
1611
  }
1612
+ return this.dispatchInternal(input, false);
1316
1613
  }
1317
- /** Reset the task-lease reaper's per-task clock (condition (c) in the "task-lease reaper" section below). */
1318
- recordTaskActivity(taskId) {
1319
- this.taskActivity.set(taskId, Date.now());
1614
+ async dispatchFreshAgentEgress(input) {
1615
+ if (Object.prototype.hasOwnProperty.call(input, "sessionRef")) {
1616
+ throw new Error("fresh Agent egress dispatch must not carry sessionRef");
1617
+ }
1618
+ if (typeof input.deviceId !== "string" || input.deviceId.length === 0) {
1619
+ throw new Error("fresh Agent egress dispatch requires an explicit deviceId");
1620
+ }
1621
+ if (input.agentRef === void 0 || input.egressPolicy === void 0) {
1622
+ throw new Error("fresh Agent egress dispatch requires exact AgentRef and egress policy");
1623
+ }
1624
+ return this.dispatchInternal(input, true);
1320
1625
  }
1321
- /**
1322
- * Ownership (record.deviceId matching the connection's authenticated
1323
- * deviceId) is enforced centrally by {@link handleInbound} (N2) before this
1324
- * runs; only the idempotent-claim CAS and the first-claim device patch
1325
- * happen here.
1326
- *
1327
- * M5 (claimed runtime, docs/protocol.md §3.1): `payload.runtime` — the
1328
- * ACTUAL adapter the daemon selected (`TaskRunner.pickAdapter`,
1329
- * `packages/client`'s `task-runner.ts`) is recorded into
1330
- * `TaskSnapshot.claimedRuntime` alongside the device patch, distinct from
1331
- * the pre-existing `TaskSnapshot.runtime` (the merely REQUESTED runtime,
1332
- * untouched here and set only once, at `dispatch()` time). Only ever
1333
- * written on the FIRST real claim: the idempotent-CAS early return above
1334
- * fires before this for a retried claim from a device that already owns
1335
- * the task, so a redelivered/retried `task.claim` can never overwrite an
1336
- * already-recorded `claimedRuntime` including with a stale or absent
1337
- * value from an out-of-order retry.
1338
- *
1339
- * S0/D-4 (claim-time capability snapshot): `payload.capabilities` — the
1340
- * claiming adapter's OWN self-report, carried on this same `task.claim`
1341
- * (docs/protocol.md §2.4) supplies
1342
- * `TaskSnapshot.claimedRuntimeCapabilities`, written in the same patch and
1343
- * therefore under the same write-exactly-once property as `claimedRuntime`.
1344
- *
1345
- * Taken from the payload and from nowhere else. This hub deliberately does
1346
- * NOT consult connection state (`conn.hello.runtimes[]`) for it — see
1347
- * {@link SteerRejectedError} for why that source is structurally wrong for a
1348
- * control decision, and that field's own doc comment (`types.ts`) for why
1349
- * this is snapshotted rather than read live at steer time. A claim that
1350
- * carries no `capabilities` (a pre-D-4 daemon) records `undefined`, which
1351
- * the gate reads as "unknown" and refuses.
1352
- */
1353
- onClaim(deviceId, taskId, payload) {
1354
- const record = this.taskStore.get(taskId);
1355
- if (!record) return;
1356
- if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1357
- console.warn(`[byok/server] dropping task.claim for ${taskId}: offered to a different device`);
1358
- return;
1626
+ async dispatchInternal(input, freshAgentEgress) {
1627
+ const sessionRef = "sessionRef" in input ? input.sessionRef : void 0;
1628
+ const agentRef = input.agentRef === void 0 ? void 0 : AgentRefSchema.parse(input.agentRef);
1629
+ const egressPolicy = input.egressPolicy === void 0 ? void 0 : AgentEgressPolicySchema.parse(input.egressPolicy);
1630
+ const dispatchSelection = input.dispatchSelection === void 0 ? void 0 : DispatchSelectionSchema.parse(input.dispatchSelection);
1631
+ const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
1632
+ const deviceId = input.deviceId ?? this.pickFirstConnectedDevice(requiredToolsets);
1633
+ if (agentRef !== void 0 && input.deviceId === void 0) {
1634
+ throw new Error("Agent-bound dispatch requires an explicit deviceId for capability admission");
1635
+ }
1636
+ if (egressPolicy !== void 0 && agentRef === void 0) {
1637
+ throw new Error("Agent egress policy requires an explicit AgentRef; legacy task dispatch cannot consume it");
1638
+ }
1639
+ if (!deviceId || !this.connections.get(deviceId)?.connected) {
1640
+ throw new Error(
1641
+ deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
1642
+ );
1643
+ }
1644
+ if (agentRef !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("agent-home-contract") ?? false)) {
1645
+ throw new Error(
1646
+ `device ${deviceId} did not advertise agent-home-contract capability; refusing Agent-bound dispatch`
1647
+ );
1648
+ }
1649
+ const requiredEgressCapabilities = freshAgentEgress ? [
1650
+ AGENT_EGRESS_POLICY_CAPABILITY,
1651
+ AGENT_EGRESS_RELIABLE_ACK_CAPABILITY,
1652
+ AGENT_EGRESS_FRESH_SESSION_CAPABILITY
1653
+ ] : [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY];
1654
+ if (egressPolicy !== void 0 && !this.hasDeviceCapabilities(deviceId, requiredEgressCapabilities)) {
1655
+ throw new Error(
1656
+ freshAgentEgress ? `device ${deviceId} did not advertise Agent egress policy, reliable acknowledgement, and fresh-session capabilities; refusing before enqueue` : `device ${deviceId} did not advertise Agent egress policy and reliable acknowledgement capabilities; refusing before enqueue`
1657
+ );
1658
+ }
1659
+ if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
1660
+ throw new Error(
1661
+ `device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
1662
+ );
1663
+ }
1664
+ if (requiredToolsets !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("toolset-selection") ?? false)) {
1665
+ throw new Error(
1666
+ `device ${deviceId} did not advertise toolset-selection capability; refusing a task whose semantics require local MCP tools`
1667
+ );
1668
+ }
1669
+ if (requiredToolsets !== void 0) {
1670
+ const configuredToolsets = this.connections.get(deviceId)?.configuredToolsets;
1671
+ if (configuredToolsets === void 0) {
1672
+ throw new Error(
1673
+ `device ${deviceId} did not advertise its configured toolset inventory; refusing to guess from runtime capability`
1674
+ );
1675
+ }
1676
+ const configured = new Set(configuredToolsets);
1677
+ const missing = requiredToolsets.filter((toolsetId) => !configured.has(toolsetId));
1678
+ if (missing.length > 0) {
1679
+ throw new Error(
1680
+ `device ${deviceId} is missing required MCP toolset(s): ${missing.join(", ")}`
1681
+ );
1682
+ }
1359
1683
  }
1360
- if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1361
- this.forceFailOrDrop(taskId, "task.claim AgentRef does not exactly match the offered AgentRef");
1362
- return;
1684
+ if (dispatchSelection !== void 0 && input.runtime !== void 0 && input.runtime !== dispatchSelection.runtimeId) {
1685
+ throw new Error(
1686
+ `dispatch runtime ${input.runtime} does not match dispatchSelection.runtimeId ${dispatchSelection.runtimeId}`
1687
+ );
1363
1688
  }
1364
- if (record.state === "Claimed" || record.state === "Running") return;
1365
- this.applyOrFail(taskId, "Claimed", {
1689
+ const taskId = generateTaskId();
1690
+ const policy = input.policy ?? DEFAULT_POLICY;
1691
+ const runtime = dispatchSelection?.runtimeId ?? input.runtime;
1692
+ const record = this.taskStore.create({
1693
+ taskId,
1694
+ instruction: input.instruction,
1695
+ runtime,
1696
+ policy,
1697
+ requiredToolsets,
1366
1698
  deviceId,
1367
- claimedRuntime: payload.runtime,
1368
- claimedRuntimeCapabilities: payload.capabilities
1699
+ sessionRef,
1700
+ agentRef
1369
1701
  });
1702
+ const queue = new AsyncEventQueue();
1703
+ let resolveResult;
1704
+ const result = new Promise((resolve) => {
1705
+ resolveResult = resolve;
1706
+ });
1707
+ this.runtimes.set(taskId, { queue, resolveResult, result });
1708
+ queue.push({ kind: "state", state: record.state, at: record.createdAt });
1709
+ this.serverEvents.push({ kind: "task.created", taskId, at: record.createdAt });
1710
+ const commonOffer = {
1711
+ instruction: input.instruction,
1712
+ policy,
1713
+ runtime,
1714
+ dispatchSelection,
1715
+ sessionRef
1716
+ };
1717
+ if (agentRef !== void 0 && egressPolicy !== void 0 && freshAgentEgress) {
1718
+ const freshOffer = {
1719
+ instruction: input.instruction,
1720
+ policy,
1721
+ runtime,
1722
+ dispatchSelection,
1723
+ agentRef,
1724
+ egressPolicy,
1725
+ ...requiredToolsets === void 0 ? {} : { requiredToolsets }
1726
+ };
1727
+ this.sendToDevice(
1728
+ deviceId,
1729
+ "task.offer_for_agent_with_egress_fresh",
1730
+ freshOffer,
1731
+ { taskId }
1732
+ );
1733
+ } else if (agentRef !== void 0 && egressPolicy !== void 0) {
1734
+ this.sendToDevice(
1735
+ deviceId,
1736
+ "task.offer_for_agent_with_egress",
1737
+ {
1738
+ ...commonOffer,
1739
+ sessionRef,
1740
+ agentRef,
1741
+ egressPolicy,
1742
+ ...requiredToolsets === void 0 ? {} : { requiredToolsets }
1743
+ },
1744
+ { taskId, sessionRef }
1745
+ );
1746
+ } else if (agentRef !== void 0) {
1747
+ this.sendToDevice(
1748
+ deviceId,
1749
+ "task.offer_for_agent",
1750
+ { ...commonOffer, agentRef, ...requiredToolsets === void 0 ? {} : { requiredToolsets } },
1751
+ { taskId, sessionRef }
1752
+ );
1753
+ } else if (requiredToolsets === void 0) {
1754
+ this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef });
1755
+ } else {
1756
+ this.sendToDevice(
1757
+ deviceId,
1758
+ "task.offer_with_toolsets",
1759
+ { ...commonOffer, requiredToolsets },
1760
+ { taskId, sessionRef }
1761
+ );
1762
+ }
1763
+ return this.buildTaskHandle(taskId);
1370
1764
  }
1371
- /**
1372
- * `Claimed -> Running` (§3.1) — a daemon actually starting the runtime
1373
- * session, distinct from merely claiming. Ownership is already enforced
1374
- * by {@link handleInbound} (N2) before this runs.
1375
- */
1376
- onStarted(taskId, _payload) {
1377
- const record = this.taskStore.get(taskId);
1378
- if (!record) return;
1379
- if (record.state === "Running") return;
1380
- if (isTerminal(record.state)) return;
1381
- this.applyOrFail(taskId, "Running", {});
1382
- }
1383
- /**
1384
- * `Offered -> Failed` (§3.2) — a fail-closed pre-claim rejection. Only
1385
- * ever legal from `Offered`; anything else is stale. Ownership is already
1386
- * enforced by {@link handleInbound} (N2) before this runs.
1387
- */
1388
- onDecline(deviceId, taskId, payload) {
1389
- const record = this.taskStore.get(taskId);
1390
- if (!record) return;
1391
- if (record.state !== "Offered") return;
1392
- if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1393
- console.warn(`[byok/server] dropping task.decline for ${taskId}: offered to a different device`);
1394
- return;
1765
+ /** Capability-gated control-plane read request; no request enters the outbox on omission. */
1766
+ async requestAgentContentRead(input) {
1767
+ const payload = AgentContentReadPayloadSchema.parse(input.payload);
1768
+ const deviceId = input.deviceId;
1769
+ if (!this.connections.get(deviceId)?.connected) {
1770
+ throw new Error(`device ${deviceId} is not connected`);
1395
1771
  }
1396
- if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1397
- this.forceFailOrDrop(taskId, "task.decline AgentRef does not exactly match the offered AgentRef");
1398
- return;
1772
+ const capability = contentReadCapability(payload.surface);
1773
+ if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1774
+ throw new Error(
1775
+ `device ${deviceId} did not advertise ${capability} and reliable acknowledgement support; refusing Agent content read before enqueue`
1776
+ );
1399
1777
  }
1400
- this.applyOrFail(taskId, "Failed", {
1401
- result: { state: "Failed", reason: payload.reason, retryable: payload.retryable }
1402
- });
1778
+ const key = `${deviceId}\0${payload.requestId}`;
1779
+ const existing = this.agentContentReadRequests.get(key);
1780
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(payload)) {
1781
+ throw new Error(`Agent content request ${payload.requestId} already exists with a different immutable body`);
1782
+ }
1783
+ this.agentContentReadRequests.set(key, payload);
1784
+ this.sendToDevice(deviceId, "agent.content.read", payload, {});
1403
1785
  }
1404
- onProgress(taskId, payload) {
1405
- const record = this.taskStore.get(taskId);
1406
- if (!record) return;
1407
- const resumed = this.resumeIfImplicitlyApproved(record);
1408
- if (resumed.state !== "Running") {
1409
- this.forceFailOrDrop(taskId, "task.progress received while not Running");
1410
- return;
1786
+ /** Task-free exact-device projection; no task record, runtime or session is created. */
1787
+ async enqueueAgentHomeProjection(input) {
1788
+ const payload = AgentHomeProjectionPayloadSchema.parse(input.payload);
1789
+ const deviceId = input.deviceId;
1790
+ if (!this.connections.get(deviceId)?.connected) {
1791
+ throw new Error(`device ${deviceId} is not connected`);
1411
1792
  }
1412
- const runtime = this.runtimes.get(taskId);
1413
- if (!runtime) return;
1414
- for (const event of payload.events) {
1415
- runtime.queue.push({ kind: "agent", event });
1793
+ if (!this.hasDeviceCapabilities(deviceId, ["agent-home-contract", AGENT_HOME_PROJECTION_CAPABILITY])) {
1794
+ throw new Error(
1795
+ `device ${deviceId} did not advertise ${AGENT_HOME_PROJECTION_CAPABILITY}; refusing Agent-home projection before enqueue`
1796
+ );
1416
1797
  }
1798
+ const key = `${deviceId}\0${payload.requestId}`;
1799
+ const existing = this.agentHomeProjectionRequests.get(key);
1800
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(payload)) {
1801
+ throw new Error(`Agent-home projection ${payload.requestId} already exists with a different immutable body`);
1802
+ }
1803
+ if (existing === void 0) {
1804
+ this.agentHomeProjectionRequests.set(key, payload);
1805
+ this.sendToDevice(deviceId, "agent.home.projection", payload, { id: payload.requestId });
1806
+ }
1807
+ const readback = this.readAgentHomeProjection(deviceId, payload.requestId);
1808
+ if (readback === void 0) throw new Error("Agent-home projection request was not retained");
1809
+ return readback;
1810
+ }
1811
+ readAgentHomeProjection(deviceId, requestId) {
1812
+ const key = `${deviceId}\0${requestId}`;
1813
+ const completion = this.agentHomeProjectionCompletions.get(key);
1814
+ if (completion !== void 0) return completion;
1815
+ const desired = this.agentHomeProjectionRequests.get(key);
1816
+ if (desired === void 0) return void 0;
1817
+ const device = this.devices.resolveByDeviceId(deviceId);
1818
+ if (device === void 0 || device.revoked) return void 0;
1819
+ return AgentHomeProjectionReadbackSchema.parse({
1820
+ tenantId: device.tenantId,
1821
+ deviceId,
1822
+ requestId: desired.requestId,
1823
+ agentRef: desired.agentRef,
1824
+ projectionHash: desired.projectionHash,
1825
+ status: "pending"
1826
+ });
1417
1827
  }
1418
- onArtifact(taskId, payload) {
1419
- const record = this.taskStore.get(taskId);
1420
- if (!record) return;
1421
- const resumed = this.resumeIfImplicitlyApproved(record);
1422
- if (resumed.state !== "Running") {
1423
- this.forceFailOrDrop(taskId, "task.artifact received while not Running");
1424
- return;
1828
+ completeAgentHomeProjection(deviceId, input) {
1829
+ const completion = AgentHomeProjectionCompletionRequestSchema.parse(input);
1830
+ const key = `${deviceId}\0${completion.requestId}`;
1831
+ const desired = this.agentHomeProjectionRequests.get(key);
1832
+ if (desired === void 0) {
1833
+ throw new AgentHomeProjectionCompletionError("not_found", "Agent-home projection request was not found");
1425
1834
  }
1426
- const runtime = this.runtimes.get(taskId);
1427
- if (!runtime) return;
1428
- runtime.queue.push({ kind: "artifact", artifact: payload });
1429
- }
1430
- onAwaitApproval(taskId, payload) {
1431
- const record = this.taskStore.get(taskId);
1432
- if (!record) return;
1433
- if (record.state === "AwaitApproval") {
1434
- if (payload.approvalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1435
- this.taskStore.setPendingApprovalId?.(taskId, payload.approvalId);
1436
- this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
1835
+ if (JSON.stringify(desired.agentRef) !== JSON.stringify(completion.agentRef) || desired.projectionHash !== completion.projectionHash) {
1836
+ throw new AgentHomeProjectionCompletionError("invalid", "Agent-home projection completion identity mismatch");
1837
+ }
1838
+ const existing = this.agentHomeProjectionCompletions.get(key);
1839
+ if (existing !== void 0) {
1840
+ if (existing.status !== completion.outcome) {
1841
+ throw new AgentHomeProjectionCompletionError("conflict", "Agent-home projection terminal outcome changed");
1437
1842
  }
1438
- return;
1843
+ return existing;
1439
1844
  }
1440
- this.applyOrFail(taskId, "AwaitApproval", { pendingApprovalId: payload.approvalId });
1441
- const after = this.taskStore.get(taskId);
1442
- if (after?.state !== "AwaitApproval") return;
1443
- this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
1444
- }
1445
- onComplete(taskId, payload) {
1446
- const record = this.taskStore.get(taskId);
1447
- if (!record) return;
1448
- if (isTerminal(record.state)) return;
1449
- if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1450
- this.forceFailOrDrop(taskId, "task.complete AgentRef does not exactly match the offered AgentRef");
1451
- return;
1845
+ const device = this.devices.resolveByDeviceId(deviceId);
1846
+ if (device === void 0 || device.revoked) {
1847
+ throw new AgentHomeProjectionCompletionError("not_found", "Agent-home projection device was not found");
1452
1848
  }
1453
- this.resumeIfImplicitlyApproved(record);
1454
- const result = {
1455
- state: "Complete",
1456
- summary: payload.summary,
1457
- sessionRef: payload.sessionRef,
1458
- artifactRefs: payload.artifactRefs,
1459
- // additive-minor (`task.complete.document`): projected verbatim, the
1460
- // same way `summary`/`artifactRefs` are. Nothing to validate or
1461
- // measure here — the payload only got this far because
1462
- // `TaskCompletePayloadSchema`'s own refinement already enforced
1463
- // JSON-serializability and `RESULT_DOCUMENT_MAX_BYTES` at the inbound
1464
- // boundary, and re-checking would make this a second authority for a
1465
- // rule the wire already owns. Stays `undefined` for the two cases that
1466
- // never carry one: a daemon with no extractor configured, and a
1467
- // pre-`result-document` daemon build.
1468
- document: payload.document
1469
- };
1470
- this.applyOrFail(taskId, "Complete", { result, sessionRef: payload.sessionRef });
1849
+ const readback = AgentHomeProjectionReadbackSchema.parse({
1850
+ tenantId: device.tenantId,
1851
+ deviceId,
1852
+ requestId: desired.requestId,
1853
+ agentRef: desired.agentRef,
1854
+ projectionHash: desired.projectionHash,
1855
+ status: completion.outcome,
1856
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
1857
+ });
1858
+ this.agentHomeProjectionCompletions.set(key, readback);
1859
+ return readback;
1471
1860
  }
1472
- onFail(taskId, payload) {
1473
- const record = this.taskStore.get(taskId);
1474
- if (!record) return;
1475
- if (isTerminal(record.state)) return;
1476
- if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1477
- this.forceFailOrDrop(taskId, "task.fail AgentRef does not exactly match the offered AgentRef");
1478
- return;
1479
- }
1480
- const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
1481
- this.applyOrFail(taskId, "Failed", { result });
1861
+ buildTaskHandle(taskId) {
1862
+ const hub = this;
1863
+ return {
1864
+ taskId,
1865
+ events() {
1866
+ const runtime = hub.runtimes.get(taskId);
1867
+ if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
1868
+ return runtime.queue.subscribe();
1869
+ },
1870
+ cancel(reason) {
1871
+ return hub.cancelTask(taskId, reason);
1872
+ },
1873
+ approve(opts) {
1874
+ return hub.approveTask(taskId, opts);
1875
+ },
1876
+ reject(reason, opts) {
1877
+ return hub.rejectTask(taskId, reason, opts);
1878
+ },
1879
+ steer(text) {
1880
+ return hub.steerTask(taskId, text);
1881
+ },
1882
+ result() {
1883
+ const runtime = hub.runtimes.get(taskId);
1884
+ if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
1885
+ return runtime.result;
1886
+ }
1887
+ };
1482
1888
  }
1483
- /**
1484
- * Dual-purpose on receipt (§3.3): if the server already moved this task to
1485
- * `Cancelled` on its own action (the common case — `cancelTask()` is
1486
- * authoritative immediately, §4), this is a late idempotent ack — silent,
1487
- * not a warning (this is the other half of the M0 gatekeeper finding this
1488
- * change resolves). Otherwise it's the authoritative trigger for a
1489
- * cancellation the daemon observed that the server didn't initiate.
1490
- * Ownership is already enforced by {@link handleInbound} (N2) before this
1491
- * runs.
1492
- */
1493
- onCancelled(taskId, payload) {
1889
+ /** Idempotent: cancelling an already-terminal task is a no-op, not an error. */
1890
+ async cancelTask(taskId, reason) {
1494
1891
  const record = this.taskStore.get(taskId);
1495
- if (!record) return;
1496
- if (record.state === "Cancelled") return;
1892
+ if (!record) throw new Error(`unknown taskId: ${taskId}`);
1497
1893
  if (isTerminal(record.state)) return;
1498
- if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1499
- this.forceFailOrDrop(taskId, "task.cancelled AgentRef does not exactly match the offered AgentRef");
1500
- return;
1894
+ this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason } });
1895
+ if (record.deviceId) {
1896
+ this.sendToDevice(record.deviceId, "task.cancel", { reason }, { taskId });
1501
1897
  }
1502
- this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
1503
1898
  }
1504
1899
  /**
1505
- * M4 (additive-minor, `task.approval_resolved`): the EXPLICIT counterpart
1506
- * to {@link resumeIfImplicitlyApproved} a daemon that resolved a pending
1507
- * approval entirely LOCALLY now reports it immediately, instead of the
1508
- * server only finding out after the fact once evidence (a later
1509
- * `task.progress`/`task.artifact`/`task.complete`) proves it.
1510
- *
1511
- * Relationship to the implicit path (both stay, permanently — this is not
1512
- * a replacement): {@link resumeIfImplicitlyApproved} remains completely
1513
- * untouched as the fallback for (a) an old daemon that predates this
1514
- * message, and (b) a daemon connected to an old server that never
1515
- * advertised the `approval_resolved` capability flag (`version.ts`) at
1516
- * handshake time — in either case the daemon never sends this message at
1517
- * all (see `packages/client`'s `task-runner.ts`), and the server keeps
1518
- * inferring the resolution from evidence exactly as it did before this
1519
- * message existed. When THIS message does arrive first, it already moves
1520
- * the record out of `AwaitApproval` (see below) — so by the time any
1521
- * following `task.progress`/etc. reaches `onProgress`/`onArtifact`/
1522
- * `onComplete`, `resumeIfImplicitlyApproved`'s own `record.state !==
1523
- * 'AwaitApproval'` guard is already true and it no-ops, never firing its
1524
- * own `task.approval_resolved_implicit` event a second time for the same
1525
- * resolution. The two mechanisms race harmlessly: whichever one the
1526
- * server processes first is the one that actually performs the
1527
- * transition; the other is naturally inert once it runs.
1528
- *
1529
- * Three outcomes, mirroring this file's existing per-type idempotency
1530
- * conventions:
1531
- * - `AwaitApproval` (the expected case): legal transition to `Running`
1532
- * (an existing `TASK_TRANSITIONS` edge, the same one `approveTask`
1533
- * itself uses) plus a `task.approval_resolved` {@link ByokServerEvent}
1534
- * carrying `approvalId`/`decision`/`resolvedBy` for an embedder to
1535
- * observe.
1536
- * - Already `Running` (evidence — or the implicit path — already beat
1537
- * this message to it): idempotent no-op, silent, mirroring
1538
- * `onStarted`'s own already-running guard.
1539
- * - Terminal, or a state that was never `AwaitApproval` in the first
1540
- * place (`Offered`/`Claimed` — a genuinely out-of-sequence report):
1541
- * stale no-op with a `console.warn`, matching this file's existing
1542
- * stale-message convention (`forceFailOrDrop`, `handleInbound`'s
1543
- * ownership-mismatch drop) — never force-failed, since a late/
1544
- * redelivered report about a task that has already moved on is not
1545
- * evidence of anything currently wrong with it.
1546
- *
1547
- * This is also the residual-race resolution the accompanying protocol/docs
1548
- * update documents: a SaaS decision (`approveTask`/`rejectTask`) already in
1549
- * flight when the local resolution happens can still land on the server
1550
- * FIRST and move the record to a terminal state before this message
1551
- * arrives — in that case this message hits the terminal branch above and
1552
- * is a stale no-op, exactly like any other late message for an
1553
- * already-terminal task. The window for that crossing is now
1554
- * network-latency-sized (how long this message takes to arrive), not
1555
- * "until the next progress message" the way the pre-existing implicit-only
1556
- * inference left it.
1900
+ * M4 Phase 3: made public (was private through M3) so an embedder can call
1901
+ * it directly from its own operator-facing surface there is no
1902
+ * bearer-authed HTTP route for this on `http.ts`'s own app (see
1903
+ * `UnknownTaskError`'s own doc comment for why, and
1904
+ * `examples/basic/server.ts`'s `/api/tasks/:taskId/approve` for the
1905
+ * intended shape of that embedder-built surface). See this file's own
1906
+ * `UnknownTaskError`/`TaskNotAwaitingApprovalError` doc comments for why
1907
+ * the two failure modes are now distinct typed errors rather than a
1908
+ * single generic `Error`. Every thrown message's TEXT is byte-for-byte
1909
+ * unchanged from M2/M3 only the error's type changed (this is still also
1910
+ * reachable via `TaskHandle.approve()`, unaffected).
1557
1911
  */
1558
- onApprovalResolved(taskId, payload) {
1912
+ /**
1913
+ * M5 (approval targeting, docs/protocol.md §5.3): `opts.approvalId`
1914
+ * targets a SPECIFIC pending approval rather than "whichever one is
1915
+ * currently pending" (the pre-M5 default, unchanged when `opts` is
1916
+ * omitted). Validated FIRST, before any state change or wire send: if
1917
+ * `opts.approvalId` is supplied and this hub has a recorded
1918
+ * `pendingApprovalId` for `taskId` that DIFFERS, throws
1919
+ * {@link StaleApprovalError} — no transition, no `task.approve` sent. If
1920
+ * this hub never recorded a `pendingApprovalId` (a legacy daemon that
1921
+ * never reported one), the call proceeds untargeted exactly as before.
1922
+ * The outgoing `task.approve` carries `approvalId`: the caller-supplied
1923
+ * one if given, else this hub's own recorded one, else omitted entirely
1924
+ * (legacy wire shape) — so the daemon can apply its own exact-match check
1925
+ * whenever this server has an id to offer at all.
1926
+ */
1927
+ async approveTask(taskId, opts) {
1559
1928
  const record = this.taskStore.get(taskId);
1560
- if (!record) return;
1561
- if (record.state === "Running") return;
1929
+ if (!record) throw new UnknownTaskError(taskId);
1562
1930
  if (record.state !== "AwaitApproval") {
1563
- console.warn(
1564
- `[byok/server] dropping task.approval_resolved for ${taskId}: not awaiting approval (state ${record.state})`
1565
- );
1566
- return;
1931
+ throw new TaskNotAwaitingApprovalError(taskId, record.state, "approve");
1567
1932
  }
1568
- if (payload.approvalId !== void 0 && record.pendingApprovalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1569
- console.warn(
1570
- `[byok/server] stale task.approval_resolved for ${taskId}: reported approvalId ${payload.approvalId} does not match the currently pending ${record.pendingApprovalId}`
1571
- );
1572
- return;
1933
+ if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
1934
+ throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
1573
1935
  }
1574
1936
  this.applyOrFail(taskId, "Running", {});
1575
- const after = this.taskStore.get(taskId);
1576
- if (after?.state !== "Running") return;
1577
- const targeted = record.deviceId !== void 0 && (this.getDeviceCapabilities(record.deviceId)?.includes("approval-targeting") ?? false);
1578
- this.serverEvents.push({
1579
- kind: "task.approval_resolved",
1580
- taskId,
1581
- approvalId: payload.approvalId,
1582
- decision: payload.decision,
1583
- resolvedBy: payload.resolvedBy,
1584
- at: after.updatedAt,
1585
- targeted
1586
- });
1587
- }
1588
- // ---------------------------------------------------------------------
1589
- // transition helpers — the single place "illegal transition" is handled
1590
- // ---------------------------------------------------------------------
1591
- /**
1592
- * M5 (approval targeting): single low-level wrapper around
1593
- * `TaskStore.transition` that every ACTUAL state-changing write in this
1594
- * file goes through — {@link applyOrFail}'s legal-transition branch,
1595
- * {@link forceFailOrDrop}, and {@link resumeIfImplicitlyApproved} (the one
1596
- * caller that transitions WITHOUT going through `applyOrFail` at all).
1597
- * Two responsibilities, folded in here once rather than duplicated at
1598
- * each call site:
1599
- *
1600
- * 1. Clears `pendingApprovalId` whenever `record` is LEAVING
1601
- * `AwaitApproval` (`record.state === 'AwaitApproval' && to !==
1602
- * 'AwaitApproval'`) — the id this hub last recorded for a task's
1603
- * pending approval ({@link onAwaitApproval}) is meaningless the
1604
- * instant that task is no longer awaiting it. Clearing it here,
1605
- * centrally, is what guarantees a FUTURE `AwaitApproval` cycle for
1606
- * the SAME task always starts from a clean slate instead of silently
1607
- * inheriting a stale id from a previous cycle (which would make a
1608
- * stale-approval check against the NEW cycle's real pending id
1609
- * spuriously pass just because a leftover value happened to still be
1610
- * sitting in the record).
1611
- * 2. Calls {@link onStateChange} — every call site already did this
1612
- * immediately after its own `transition` call; folding it in here
1613
- * removes the duplication and the chance of a future call site
1614
- * forgetting it.
1615
- */
1616
- transitionTask(taskId, record, to, patch) {
1617
- const finalPatch = record.state === "AwaitApproval" && to !== "AwaitApproval" ? { ...patch, pendingApprovalId: void 0 } : patch;
1618
- const updated = this.taskStore.transition(taskId, to, finalPatch);
1619
- this.onStateChange(updated);
1620
- return updated;
1621
- }
1622
- /**
1623
- * Apply `taskId`'s state -> `target`. If that's illegal per
1624
- * `TASK_TRANSITIONS`, fall back to `Failed` (if reachable from the current
1625
- * state); this is the "illegal transition = error + task.fail path" rule.
1626
- */
1627
- applyOrFail(taskId, target, patch) {
1628
- const record = this.taskStore.get(taskId);
1629
- if (!record) return;
1630
- if (canTransition(record.state, target)) {
1631
- this.transitionTask(taskId, record, target, patch);
1632
- return;
1937
+ if (record.deviceId) {
1938
+ const approvalId = opts?.approvalId ?? record.pendingApprovalId;
1939
+ this.sendToDevice(record.deviceId, "task.approve", { approvalId }, { taskId });
1633
1940
  }
1634
- this.forceFailOrDrop(taskId, `illegal transition ${record.state} -> ${target}`);
1635
- }
1636
- /**
1637
- * M4 Phase 3 hardening (orchestrator-directed fix for the server-state-
1638
- * machine trace finding): a task can be resolved entirely OUT-OF-BAND, on
1639
- * the daemon side only (M4 Phase 3's local `approvals.resolve`
1640
- * control-socket path, `packages/client`) — the server never sees a wire
1641
- * `task.approve`/`task.reject` for it, so its own record sits in
1642
- * `AwaitApproval` even though the daemon already resumed and moved on.
1643
- *
1644
- * The daemon is the execution authority in this security model (the SaaS
1645
- * only ever *proposes* — see docs/spec.md); the daemon sending ANY further
1646
- * task.* traffic for a task the server still thinks is `AwaitApproval` is
1647
- * itself sufficient proof the approval was resolved locally, one way or
1648
- * another. Rather than force-failing/dropping that traffic (the pre-fix
1649
- * behavior — `onProgress`/`onArtifact`'s own `!== 'Running'` guard,
1650
- * `onComplete`'s illegal-transition fallback), this applies the exact same
1651
- * `AwaitApproval -> Running` edge `approveTask` already uses (a
1652
- * pre-existing legal `TASK_TRANSITIONS` edge, not a new one) through the
1653
- * normal transition path — `taskStore.transition` + `onStateChange`, same
1654
- * as `applyOrFail`'s own legal-transition branch — so every existing
1655
- * consumer of task state (§, `TaskHandle.events()`, the lease reaper's
1656
- * `taskActivity`) observes it exactly as it would a real wire
1657
- * `task.approve`. Then emits `task.approval_resolved_implicit` (a
1658
- * `ByokServerEvent`, NOT a wire message — see that type's own doc comment)
1659
- * so an embedder can distinguish this from an operator-driven approval.
1660
- *
1661
- * M4 (additive-minor, superseding this method's own former "deferred"
1662
- * framing): a first-class `task.approval_resolved` WIRE notification now
1663
- * exists (`onApprovalResolved`, below) — a daemon that supports it, talking
1664
- * to a server that advertised the `approval_resolved` capability flag
1665
- * (`version.ts`), reports a local resolution explicitly and immediately
1666
- * instead of leaving the server to infer it here. This method is
1667
- * UNTOUCHED and remains the permanent fallback for the N/N-1 cases where
1668
- * that explicit report never arrives (an old daemon, or an old server this
1669
- * daemon is talking to) — see `onApprovalResolved`'s own doc comment for
1670
- * the full relationship between the two paths, including why they can
1671
- * never both fire for the same resolution.
1672
- *
1673
- * No-op (returns `record` unchanged) for any state other than
1674
- * `AwaitApproval` — every other guard (terminal, pre-claim, already-
1675
- * Running) keeps exactly its current behavior. `onFail`/`onCancelled`
1676
- * never call this: `Failed`/`Cancelled` are already direct, legal edges
1677
- * from `AwaitApproval`, so they never hit the illegal-transition path this
1678
- * exists to avoid in the first place.
1679
- */
1680
- resumeIfImplicitlyApproved(record) {
1681
- if (record.state !== "AwaitApproval") return record;
1682
- const updated = this.transitionTask(record.taskId, record, "Running", {});
1683
- this.serverEvents.push({ kind: "task.approval_resolved_implicit", taskId: record.taskId, at: updated.updatedAt });
1684
- return updated;
1685
1941
  }
1686
1942
  /**
1687
- * A daemon message didn't fit the task's current state (e.g. progress
1688
- * while AwaitApproval). Force the task to `Failed` if that's reachable;
1689
- * otherwise it's already terminal (or `Offered`, which has no Failed edge)
1690
- * and there's nothing safe to do but log + drop.
1943
+ * M4 Phase 3: made public see {@link ConnectionHub.approveTask}'s own
1944
+ * doc comment for the full rationale (identical reasoning applies here).
1945
+ * M5: same `opts.approvalId` targeting semantics as `approveTask` above
1946
+ * see that method's own doc comment.
1691
1947
  */
1692
- forceFailOrDrop(taskId, reason) {
1948
+ async rejectTask(taskId, reason, opts) {
1693
1949
  const record = this.taskStore.get(taskId);
1694
- if (!record) return;
1695
- if (canTransition(record.state, "Failed")) {
1696
- this.transitionTask(taskId, record, "Failed", {
1697
- result: { state: "Failed", reason, retryable: false }
1698
- });
1699
- return;
1950
+ if (!record) throw new UnknownTaskError(taskId);
1951
+ if (record.state !== "AwaitApproval") {
1952
+ throw new TaskNotAwaitingApprovalError(taskId, record.state, "reject");
1953
+ }
1954
+ if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
1955
+ throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
1956
+ }
1957
+ this.applyOrFail(taskId, "Failed", {
1958
+ result: { state: "Failed", reason: reason ?? "approval rejected", retryable: false }
1959
+ });
1960
+ if (record.deviceId) {
1961
+ const approvalId = opts?.approvalId ?? record.pendingApprovalId;
1962
+ this.sendToDevice(record.deviceId, "task.reject", { reason, approvalId }, { taskId });
1700
1963
  }
1701
- console.warn(`[byok/server] dropping message for ${taskId} (state ${record.state}): ${reason}`);
1702
1964
  }
1703
- onStateChange(record) {
1704
- this.serverEvents.push({
1705
- kind: "task.state",
1706
- taskId: record.taskId,
1707
- state: record.state,
1708
- at: record.updatedAt,
1709
- // M5 (claimed runtime): mirrors the snapshot's own field verbatim —
1710
- // see ByokServerEvent's 'task.state' variant doc comment (types.ts).
1711
- claimedRuntime: record.claimedRuntime
1712
- });
1965
+ /**
1966
+ * S0 (GAP-002): a task-level gate, evaluated in full before any envelope is
1967
+ * built — see {@link SteerRejectedError} for the gap this closes and why an
1968
+ * unknown capability must refuse rather than proceed. Order matters:
1969
+ *
1970
+ * 1. unknown task — unchanged pre-S0 `Error` (this is not a steer-policy
1971
+ * decision, and `TaskHandle.steer` can only be reached with a taskId
1972
+ * this hub minted, so it's a programming error, not an operator one);
1973
+ * 2. terminal (`Complete`/`Failed`/`Cancelled`) -> `task_terminal`,
1974
+ * checked BEFORE the `Running` check so a steer racing a terminal
1975
+ * transition always resolves terminal-first;
1976
+ * 3. not `Running` (`Offered`/`Claimed`/`AwaitApproval`) ->
1977
+ * `task_not_running`;
1978
+ * 4. the claim-time snapshot does not positively say `steer: true` ->
1979
+ * `steer_unsupported_runtime`, including when there is no snapshot at
1980
+ * all (fail-closed);
1981
+ * 5. only then, the pre-existing device-liveness check and the send.
1982
+ *
1983
+ * Step 4 reads `TaskSnapshot.claimedRuntimeCapabilities` — the per-runtime,
1984
+ * per-task value frozen at claim time from the claiming adapter's own
1985
+ * `task.claim.capabilities` — and reads NO connection state whatsoever:
1986
+ * not {@link getDeviceCapabilities}, not `ConnectionState.runtimes`, and
1987
+ * with no fallback to either when the snapshot is absent. See
1988
+ * {@link SteerRejectedError} for why a connection-sourced input is wrong
1989
+ * in scope (it describes a daemon build, not this task's runtime).
1990
+ */
1991
+ async steerTask(taskId, text) {
1992
+ const record = this.taskStore.get(taskId);
1993
+ if (!record) throw new Error(`unknown taskId: ${taskId}`);
1713
1994
  if (isTerminal(record.state)) {
1714
- this.taskActivity.delete(record.taskId);
1995
+ throw new SteerRejectedError(taskId, "task_terminal", record.state, record.claimedRuntime);
1715
1996
  }
1716
- const runtime = this.runtimes.get(record.taskId);
1717
- if (!runtime) return;
1718
- runtime.queue.push({ kind: "state", state: record.state, at: record.updatedAt });
1719
- if (isTerminal(record.state)) {
1720
- runtime.resolveResult(record.result ?? { state: record.state });
1721
- runtime.queue.close();
1997
+ if (record.state !== "Running") {
1998
+ throw new SteerRejectedError(taskId, "task_not_running", record.state, record.claimedRuntime);
1999
+ }
2000
+ if (record.claimedRuntimeCapabilities?.steer !== true) {
2001
+ throw new SteerRejectedError(taskId, "steer_unsupported_runtime", record.state, record.claimedRuntime);
2002
+ }
2003
+ if (!record.deviceId || !this.connections.get(record.deviceId)?.connected) {
2004
+ throw new Error(`device for task ${taskId} is not connected`);
2005
+ }
2006
+ this.sendToDevice(record.deviceId, "task.steer", { text }, { taskId });
2007
+ }
2008
+ pickFirstConnectedDevice(requiredToolsets) {
2009
+ for (const [deviceId, conn] of this.connections) {
2010
+ if (!conn.connected) continue;
2011
+ if (requiredToolsets === void 0) return deviceId;
2012
+ if (!conn.capabilities?.includes("toolset-selection")) continue;
2013
+ if (conn.configuredToolsets === void 0) continue;
2014
+ const configured = new Set(conn.configuredToolsets);
2015
+ if (requiredToolsets.every((toolsetId) => configured.has(toolsetId))) return deviceId;
1722
2016
  }
2017
+ return void 0;
1723
2018
  }
1724
2019
  // ---------------------------------------------------------------------
1725
- // task-lease reaper (Decision: Failed(retryable:true) on dark-device
1726
- // timeout — no new task state, no new wire message)
2020
+ // outbound envelope delivery + per-device seq/redelivery bookkeeping (§1.2, §9)
1727
2021
  // ---------------------------------------------------------------------
1728
2022
  /**
1729
- * Task lease: a backstop for a device that goes dark mid-task and never
1730
- * comes back distinct from, and layered on top of, M1's redelivery
1731
- * (docs/protocol.md §9), which already handles "device reconnects within
1732
- * the window, nothing lost." Decision (user+design): reuse the existing
1733
- * `Failed` terminal state and its `retryable` flag —
1734
- * `Failed(retryable: true, reason: 'lease-expired')` — exactly like any
1735
- * other `task.fail`. The embedder is expected to treat this exactly like
1736
- * any other retryable failure: re-dispatch as a brand-new task.
1737
- *
1738
- * Implemented as a periodic sweep (see the constructor), not a per-task
1739
- * timer, so a device that goes dark *after* being idle-but-connected for a
1740
- * while is still caught on a later tick without needing extra bookkeeping
1741
- * at disconnect time. `sweepLeases` reaps a task only when ALL of the
1742
- * following hold, checked fresh on every tick (never cached):
1743
- *
1744
- * (a) the task is in a non-terminal *claimed* state — `Claimed`,
1745
- * `Running`, or `AwaitApproval` ({@link isClaimedState}). `Offered`
1746
- * is excluded: it has no owning device yet, so there's nothing to
1747
- * be "dark".
1748
- * (b) the owning device is dark right now ({@link deviceDarkSince}
1749
- * returns a timestamp rather than `undefined`) — disconnected
1750
- * outright, or (long-poll only) hasn't been seen since before the
1751
- * lease window. A live WS connection is never dark from the
1752
- * reaper's point of view: `heartbeat.ts` already independently
1753
- * proves liveness at the transport level and flips
1754
- * `connected: false` via `handleDisconnect` once it stops getting
1755
- * pongs — the reaper just reads that flag rather than re-deriving
1756
- * it. `deviceDarkSince` also returns *when* darkness started
1757
- * ({@link ConnectionState.darkSince}, set the instant
1758
- * `handleDisconnect` flips the connection dark) — that instant
1759
- * feeds condition (c), below.
1760
- * (c) a full `taskLeaseMs` has elapsed since the *later* of: the task's
1761
- * own last inbound-activity timestamp ({@link taskActivity}, reset
1762
- * in {@link dispatchToHandler} on every accepted envelope for a
1763
- * known, non-terminal task — claim, started, progress, artifact,
1764
- * await_approval, anything), and (b)'s dark-since instant. Taking
1765
- * the *later* of the two — not the activity timestamp alone — is
1766
- * what makes a device going dark start a fresh, full countdown
1767
- * instead of reusing whatever (possibly already-stale) activity
1768
- * timestamp the task happened to have: a task can be legitimately
1769
- * idle *while connected* for longer than `taskLeaseMs` (a long turn
1770
- * with no progress events, or just a quiet stretch) without being
1771
- * touched — see (b) — but the instant such a task's device
1772
- * disconnects, that stale activity timestamp must NOT immediately
1773
- * satisfy (c) on its own, or the task would get reaped within one
1774
- * sweep tick of disconnect instead of waiting the full window. That
1775
- * was a real bug (a disconnect-after-long-idle reap effectively
1776
- * indistinguishable from the M0 disconnect-alone-fails-the-task
1777
- * behavior M1 removed, below); anchoring (c) to
1778
- * `max(lastActivity, darkSince)` fixes it — idle time that elapsed
1779
- * *before* the device went dark no longer counts toward the lease,
1780
- * only silence *after* dark-start does.
1781
- *
1782
- * (b) and (c) are deliberately independent clocks, not one merged check.
1783
- * The property this most exists to protect: a *connected*, momentarily
1784
- * idle device mid-turn must never be reaped, no matter how long
1785
- * `taskLeaseMs` is — condition (b) alone blocks that regardless of (c).
1786
- * This is also what keeps this from reintroducing the M0 bug M1
1787
- * deliberately removed (see `handleDisconnect`'s own doc comment above) —
1788
- * M0 force-failed a task the instant its device disconnected; M1
1789
- * correctly stopped doing that so a task could survive a disconnect and
1790
- * resume via redelivery. This reaper does not revert that: disconnect
1791
- * ALONE still does nothing here either — (c) still has to independently
1792
- * hold, and per the `max(...)` above it only will once a full
1793
- * `taskLeaseMs` has genuinely elapsed *since the device went dark*, no
1794
- * matter how stale the task's own activity timestamp already was at that
1795
- * moment.
1796
- *
1797
- * Interaction with redelivery (§9): redelivery is what handles "the
1798
- * device came back within the window" — nothing to reap, normal traffic
1799
- * resumes. This reaper is what handles "it never came back." Idempotent
1800
- * claim (`onClaim`'s CAS) still protects server-side bookkeeping if a
1801
- * device wakes up *after* its task was already reaped and retries a stale
1802
- * claim/progress/etc. for it: every per-type handler's existing
1803
- * stale/terminal-task guard (§9) drops it as a no-op, same as any other
1804
- * late message for an already-terminal task — no new guard was needed for
1805
- * that here.
2023
+ * Build a server -> daemon envelope with a fresh per-device `seq`, retain
2024
+ * it in that device's outbox ring buffer, and deliver it now if a live
2025
+ * transport is available (WS send, or wake a pending long-poll).
1806
2026
  *
1807
- * Accepted residual (by design, not a bug): idempotent claim protects
1808
- * *server-side* state, not the device's own local side effects. A dark
1809
- * device that wakes up after its task has already been reaped may still
1810
- * be mid-way through running real local work (file writes, shell
1811
- * commands, whatever the runtime adapter was doing) for a task the server
1812
- * has since moved on from — and that the embedder may have already
1813
- * re-dispatched elsewhere. There is no way to remotely guarantee a
1814
- * truly-dark device stops running; the mitigation is entirely
1815
- * `taskLeaseMs` being set far larger than any realistic task duration, so
1816
- * this can only happen to a device that was genuinely gone for a very
1817
- * long time, not a normal slow turn.
2027
+ * `opts`'s type mirrors `createEnvelope`'s own per-type conditional
2028
+ * requiredness (finding F1) minus `seq` (computed fresh right here on
2029
+ * every call, never caller-supplied) so every one of this method's 6
2030
+ * callers below must supply `taskId` for the 5 types that need it
2031
+ * (everything except `conn.ack`), same as calling `createEnvelope`
2032
+ * directly would require.
1818
2033
  */
1819
- sweepLeases() {
1820
- const now = Date.now();
1821
- for (const record of this.taskStore.list()) {
1822
- if (!isClaimedState(record.state) || !record.deviceId) continue;
1823
- const darkSince = this.deviceDarkSince(record.deviceId, now);
1824
- if (darkSince === void 0) continue;
1825
- const lastActivity = this.taskActivity.get(record.taskId) ?? Date.parse(record.updatedAt);
1826
- const silentSince = Math.max(lastActivity, darkSince);
1827
- if (now - silentSince < this.taskLeaseMs) continue;
1828
- this.reapTask(record.taskId);
2034
+ sendToDevice(deviceId, type, payload, opts) {
2035
+ this.envelopesOutCount++;
2036
+ const outbox = this.getOrCreateOutbox(deviceId);
2037
+ const seq = outbox.nextSeq++;
2038
+ const combinedOpts = { ...opts, seq };
2039
+ const envelope = createEnvelope(type, payload, combinedOpts);
2040
+ const taskId = opts.taskId;
2041
+ const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
2042
+ const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection";
2043
+ outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
2044
+ if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
2045
+ this.deliverToDevice(deviceId, envelope);
2046
+ return envelope;
2047
+ }
2048
+ deliverToDevice(deviceId, envelope) {
2049
+ const conn = this.connections.get(deviceId);
2050
+ if (conn?.connected && conn.ws) {
2051
+ conn.ws.send(encodeEnvelope(envelope));
1829
2052
  }
2053
+ this.settleLongPollWaiter(deviceId);
1830
2054
  }
1831
2055
  /**
1832
- * Condition (b) above: `undefined` while `deviceId`'s connection counts as
1833
- * alive (never reapable, no matter how stale (c) is); otherwise the
1834
- * epoch-ms instant it began counting as "dark" for lease purposes.
1835
- * `sweepLeases` combines this with (c)'s own last-activity instant via
1836
- * `max(...)` so the full `taskLeaseMs` silence window is always measured
1837
- * from whichever of the two happened later.
2056
+ * Retained envelopes for `deviceId` with `seq > cursor` that still belong
2057
+ * to a non-terminal task OR are explicitly exempted from that filter
2058
+ * (`redeliverThroughTerminal`, N1/F4: `task.cancel`/`task.reject`) in
2059
+ * `seq` order. The `seq > cursor` bound is what naturally stops an
2060
+ * exempted entry from redelivering forever: once the daemon acks it (its
2061
+ * reported cursor advances past that `seq`), it no longer qualifies here
2062
+ * on any future reconnect/poll.
2063
+ */
2064
+ collectRelevant(deviceId, cursor) {
2065
+ const outbox = this.outboxes.get(deviceId);
2066
+ if (!outbox) return [];
2067
+ return outbox.ring.filter(
2068
+ (entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
2069
+ ).map((entry) => entry.envelope);
2070
+ }
2071
+ isTaskTerminal(taskId) {
2072
+ const record = this.taskStore.get(taskId);
2073
+ return !record || isTerminal(record.state);
2074
+ }
2075
+ /** The highest `seq` assigned to `deviceId` so far — the redelivery cursor to hand back on a poll/reconnect. */
2076
+ currentCursor(deviceId) {
2077
+ const outbox = this.outboxes.get(deviceId);
2078
+ return outbox ? outbox.nextSeq - 1 : 0;
2079
+ }
2080
+ getOrCreateOutbox(deviceId) {
2081
+ let outbox = this.outboxes.get(deviceId);
2082
+ if (!outbox) {
2083
+ outbox = { nextSeq: 1, ring: [] };
2084
+ this.outboxes.set(deviceId, outbox);
2085
+ }
2086
+ return outbox;
2087
+ }
2088
+ // ---------------------------------------------------------------------
2089
+ // read-only accessors backing the public `machines` / `tasks` API
2090
+ // ---------------------------------------------------------------------
2091
+ listMachines() {
2092
+ return this.devices.list().map(({ deviceId, deviceName }) => {
2093
+ const conn = this.connections.get(deviceId);
2094
+ return {
2095
+ deviceId,
2096
+ deviceName,
2097
+ connected: conn?.connected ?? false,
2098
+ lastSeen: conn?.lastSeen,
2099
+ ...conn?.clientVersion === void 0 ? {} : { clientVersion: conn.clientVersion },
2100
+ runtimes: conn?.runtimes,
2101
+ configuredToolsets: conn?.configuredToolsets ? [...conn.configuredToolsets] : void 0
2102
+ };
2103
+ });
2104
+ }
2105
+ /**
2106
+ * M5 (approval targeting, hello-capability plumbing): the capability flags
2107
+ * `deviceId`'s CURRENT connection advertised in its `conn.hello` —
2108
+ * `undefined` if this hub has no connection state for the device at all,
2109
+ * or one that never had capabilities recorded (a pre-M5 daemon, or a
2110
+ * device this hub only ever saw over long-poll with no prior WS hello —
2111
+ * see `ConnectionState.capabilities`'s own doc comment). Read fresh from
2112
+ * live connection state, mirroring `listMachines()`'s own convention; an
2113
+ * embedder can use this to distinguish a targeting-capable device from a
2114
+ * legacy one for its own observability/UI purposes (see `version.ts`'s
2115
+ * `approval-targeting` flag doc comment for why this is informational
2116
+ * only, never a correctness gate).
1838
2117
  */
1839
- deviceDarkSince(deviceId, nowMs) {
1840
- const conn = this.connections.get(deviceId);
1841
- if (!conn || !conn.connected) {
1842
- return conn?.darkSince ?? 0;
1843
- }
1844
- if (conn.ws) return void 0;
1845
- const lastSeenMs = Date.parse(conn.lastSeen);
1846
- return nowMs - lastSeenMs >= this.taskLeaseMs ? lastSeenMs : void 0;
2118
+ getDeviceCapabilities(deviceId) {
2119
+ return this.connections.get(deviceId)?.capabilities;
1847
2120
  }
1848
- /** Reap one lease-expired task through the exact same TaskStore/canTransition path — and terminal-event emission — as any other `task.fail` (see {@link applyOrFail}). */
1849
- reapTask(taskId) {
1850
- const record = this.taskStore.get(taskId);
1851
- if (!record || isTerminal(record.state)) return;
1852
- this.applyOrFail(taskId, "Failed", {
1853
- result: { state: "Failed", reason: "lease-expired", retryable: true }
1854
- });
2121
+ hasDeviceCapabilities(deviceId, required) {
2122
+ const advertised = this.getDeviceCapabilities(deviceId);
2123
+ return advertised !== void 0 && required.every((capability) => advertised.includes(capability));
1855
2124
  }
1856
- // ---------------------------------------------------------------------
1857
- // dispatch() and the TaskHandle it returns
1858
- // ---------------------------------------------------------------------
1859
- async dispatch(input) {
1860
- if (input.egressPolicy !== void 0 && input.sessionRef === void 0) {
1861
- throw new Error("Agent egress resume dispatch requires an exact sessionRef; use dispatchFreshAgentEgress for fresh execution");
1862
- }
1863
- return this.dispatchInternal(input, false);
2125
+ getAgentEgressReceipt(deviceId, eventId) {
2126
+ return this.agentEgressReceipts.get(this.agentEgressReceiptKey(deviceId, eventId));
1864
2127
  }
1865
- async dispatchFreshAgentEgress(input) {
1866
- if (Object.prototype.hasOwnProperty.call(input, "sessionRef")) {
1867
- throw new Error("fresh Agent egress dispatch must not carry sessionRef");
1868
- }
1869
- if (typeof input.deviceId !== "string" || input.deviceId.length === 0) {
1870
- throw new Error("fresh Agent egress dispatch requires an explicit deviceId");
1871
- }
1872
- if (input.agentRef === void 0 || input.egressPolicy === void 0) {
1873
- throw new Error("fresh Agent egress dispatch requires exact AgentRef and egress policy");
1874
- }
1875
- return this.dispatchInternal(input, true);
2128
+ getTask(taskId) {
2129
+ return this.taskStore.get(taskId);
1876
2130
  }
1877
- async dispatchInternal(input, freshAgentEgress) {
1878
- const sessionRef = "sessionRef" in input ? input.sessionRef : void 0;
1879
- const agentRef = input.agentRef === void 0 ? void 0 : AgentRefSchema.parse(input.agentRef);
1880
- const egressPolicy = input.egressPolicy === void 0 ? void 0 : AgentEgressPolicySchema.parse(input.egressPolicy);
1881
- const dispatchSelection = input.dispatchSelection === void 0 ? void 0 : DispatchSelectionSchema.parse(input.dispatchSelection);
1882
- const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
1883
- const deviceId = input.deviceId ?? this.pickFirstConnectedDevice(requiredToolsets);
1884
- if (agentRef !== void 0 && input.deviceId === void 0) {
1885
- throw new Error("Agent-bound dispatch requires an explicit deviceId for capability admission");
1886
- }
1887
- if (egressPolicy !== void 0 && agentRef === void 0) {
1888
- throw new Error("Agent egress policy requires an explicit AgentRef; legacy task dispatch cannot consume it");
1889
- }
1890
- if (!deviceId || !this.connections.get(deviceId)?.connected) {
1891
- throw new Error(
1892
- deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
1893
- );
1894
- }
1895
- if (agentRef !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("agent-home-contract") ?? false)) {
1896
- throw new Error(
1897
- `device ${deviceId} did not advertise agent-home-contract capability; refusing Agent-bound dispatch`
1898
- );
1899
- }
1900
- const requiredEgressCapabilities = freshAgentEgress ? [
1901
- AGENT_EGRESS_POLICY_CAPABILITY,
1902
- AGENT_EGRESS_RELIABLE_ACK_CAPABILITY,
1903
- AGENT_EGRESS_FRESH_SESSION_CAPABILITY
1904
- ] : [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY];
1905
- if (egressPolicy !== void 0 && !this.hasDeviceCapabilities(deviceId, requiredEgressCapabilities)) {
1906
- throw new Error(
1907
- freshAgentEgress ? `device ${deviceId} did not advertise Agent egress policy, reliable acknowledgement, and fresh-session capabilities; refusing before enqueue` : `device ${deviceId} did not advertise Agent egress policy and reliable acknowledgement capabilities; refusing before enqueue`
1908
- );
1909
- }
1910
- if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
1911
- throw new Error(
1912
- `device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
1913
- );
1914
- }
1915
- if (requiredToolsets !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("toolset-selection") ?? false)) {
1916
- throw new Error(
1917
- `device ${deviceId} did not advertise toolset-selection capability; refusing a task whose semantics require local MCP tools`
1918
- );
1919
- }
1920
- if (requiredToolsets !== void 0) {
1921
- const configuredToolsets = this.connections.get(deviceId)?.configuredToolsets;
1922
- if (configuredToolsets === void 0) {
1923
- throw new Error(
1924
- `device ${deviceId} did not advertise its configured toolset inventory; refusing to guess from runtime capability`
1925
- );
1926
- }
1927
- const configured = new Set(configuredToolsets);
1928
- const missing = requiredToolsets.filter((toolsetId) => !configured.has(toolsetId));
1929
- if (missing.length > 0) {
1930
- throw new Error(
1931
- `device ${deviceId} is missing required MCP toolset(s): ${missing.join(", ")}`
1932
- );
1933
- }
1934
- }
1935
- if (dispatchSelection !== void 0 && input.runtime !== void 0 && input.runtime !== dispatchSelection.runtimeId) {
1936
- throw new Error(
1937
- `dispatch runtime ${input.runtime} does not match dispatchSelection.runtimeId ${dispatchSelection.runtimeId}`
1938
- );
1939
- }
1940
- const taskId = generateTaskId();
1941
- const policy = input.policy ?? DEFAULT_POLICY;
1942
- const runtime = dispatchSelection?.runtimeId ?? input.runtime;
1943
- const record = this.taskStore.create({
1944
- taskId,
1945
- instruction: input.instruction,
1946
- runtime,
1947
- policy,
1948
- requiredToolsets,
1949
- deviceId,
1950
- sessionRef,
1951
- agentRef
1952
- });
1953
- const queue = new AsyncEventQueue();
1954
- let resolveResult;
1955
- const result = new Promise((resolve) => {
1956
- resolveResult = resolve;
1957
- });
1958
- this.runtimes.set(taskId, { queue, resolveResult, result });
1959
- queue.push({ kind: "state", state: record.state, at: record.createdAt });
1960
- this.serverEvents.push({ kind: "task.created", taskId, at: record.createdAt });
1961
- const commonOffer = {
1962
- instruction: input.instruction,
1963
- policy,
1964
- runtime,
1965
- dispatchSelection,
1966
- sessionRef
1967
- };
1968
- if (agentRef !== void 0 && egressPolicy !== void 0 && freshAgentEgress) {
1969
- const freshOffer = {
1970
- instruction: input.instruction,
1971
- policy,
1972
- runtime,
1973
- dispatchSelection,
1974
- agentRef,
1975
- egressPolicy,
1976
- ...requiredToolsets === void 0 ? {} : { requiredToolsets }
1977
- };
1978
- this.sendToDevice(
1979
- deviceId,
1980
- "task.offer_for_agent_with_egress_fresh",
1981
- freshOffer,
1982
- { taskId }
1983
- );
1984
- } else if (agentRef !== void 0 && egressPolicy !== void 0) {
1985
- this.sendToDevice(
1986
- deviceId,
1987
- "task.offer_for_agent_with_egress",
1988
- {
1989
- ...commonOffer,
1990
- sessionRef,
1991
- agentRef,
1992
- egressPolicy,
1993
- ...requiredToolsets === void 0 ? {} : { requiredToolsets }
1994
- },
1995
- { taskId, sessionRef }
1996
- );
1997
- } else if (agentRef !== void 0) {
1998
- this.sendToDevice(
1999
- deviceId,
2000
- "task.offer_for_agent",
2001
- { ...commonOffer, agentRef, ...requiredToolsets === void 0 ? {} : { requiredToolsets } },
2002
- { taskId, sessionRef }
2003
- );
2004
- } else if (requiredToolsets === void 0) {
2005
- this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef });
2006
- } else {
2007
- this.sendToDevice(
2008
- deviceId,
2009
- "task.offer_with_toolsets",
2010
- { ...commonOffer, requiredToolsets },
2011
- { taskId, sessionRef }
2012
- );
2013
- }
2014
- return this.buildTaskHandle(taskId);
2131
+ listTasks() {
2132
+ return this.taskStore.list();
2015
2133
  }
2016
- /** Capability-gated control-plane read request; no request enters the outbox on omission. */
2017
- async requestAgentContentRead(input) {
2018
- const payload = AgentContentReadPayloadSchema.parse(input.payload);
2019
- const deviceId = input.deviceId;
2020
- if (!this.connections.get(deviceId)?.connected) {
2021
- throw new Error(`device ${deviceId} is not connected`);
2022
- }
2023
- const capability = contentReadCapability(payload.surface);
2024
- if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
2025
- throw new Error(
2026
- `device ${deviceId} did not advertise ${capability} and reliable acknowledgement support; refusing Agent content read before enqueue`
2027
- );
2134
+ // ---------------------------------------------------------------------
2135
+ // observability (M4 Phase 4, part B.1) — in-process only; see
2136
+ // `types.ts`'s `HubStats`/`CreateByokServerOptions.healthzRoute` doc
2137
+ // comments for why this is never exposed over HTTP by this SDK itself.
2138
+ // ---------------------------------------------------------------------
2139
+ /**
2140
+ * A plain, serializable snapshot of this hub's current state, derived from
2141
+ * existing structures (`connections`, `taskStore`) plus the small counters
2142
+ * this file already maintains for exactly this purpose — no new
2143
+ * bookkeeping structures beyond those counters. See {@link HubStats}
2144
+ * (`types.ts`) for the full field-by-field contract.
2145
+ */
2146
+ stats() {
2147
+ const taskCountsByState = Object.fromEntries(TASK_STATES.map((state) => [state, 0]));
2148
+ for (const record of this.taskStore.list()) {
2149
+ taskCountsByState[record.state]++;
2028
2150
  }
2029
- const key = `${deviceId}\0${payload.requestId}`;
2030
- const existing = this.agentContentReadRequests.get(key);
2031
- if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(payload)) {
2032
- throw new Error(`Agent content request ${payload.requestId} already exists with a different immutable body`);
2151
+ let connectedDeviceCount = 0;
2152
+ for (const conn of this.connections.values()) {
2153
+ if (conn.connected) connectedDeviceCount++;
2033
2154
  }
2034
- this.agentContentReadRequests.set(key, payload);
2035
- this.sendToDevice(deviceId, "agent.content.read", payload, {});
2036
- }
2037
- buildTaskHandle(taskId) {
2038
- const hub = this;
2039
2155
  return {
2040
- taskId,
2041
- events() {
2042
- const runtime = hub.runtimes.get(taskId);
2043
- if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
2044
- return runtime.queue.subscribe();
2045
- },
2046
- cancel(reason) {
2047
- return hub.cancelTask(taskId, reason);
2048
- },
2049
- approve(opts) {
2050
- return hub.approveTask(taskId, opts);
2051
- },
2052
- reject(reason, opts) {
2053
- return hub.rejectTask(taskId, reason, opts);
2054
- },
2055
- steer(text) {
2056
- return hub.steerTask(taskId, text);
2057
- },
2058
- result() {
2059
- const runtime = hub.runtimes.get(taskId);
2060
- if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
2061
- return runtime.result;
2062
- }
2156
+ connectedDeviceCount,
2157
+ taskCountsByState,
2158
+ envelopesIn: this.envelopesInCount,
2159
+ envelopesOut: this.envelopesOutCount,
2160
+ dedupDrops: this.dedupDropCount,
2161
+ rateLimitEvents: this.rateLimitEventCount,
2162
+ uptimeMs: Date.now() - this.startedAtMs
2063
2163
  };
2064
2164
  }
2065
- /** Idempotent: cancelling an already-terminal task is a no-op, not an error. */
2066
- async cancelTask(taskId, reason) {
2067
- const record = this.taskStore.get(taskId);
2068
- if (!record) throw new Error(`unknown taskId: ${taskId}`);
2069
- if (isTerminal(record.state)) return;
2070
- this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason } });
2071
- if (record.deviceId) {
2072
- this.sendToDevice(record.deviceId, "task.cancel", { reason }, { taskId });
2073
- }
2165
+ };
2166
+ var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
2167
+ var PairingCodeInvalidError = class extends Error {
2168
+ constructor(reason) {
2169
+ super(`invalid pairing code: ${reason}`);
2170
+ this.name = "PairingCodeInvalidError";
2074
2171
  }
2172
+ };
2173
+ var PairingManager = class {
2174
+ codes = /* @__PURE__ */ new Map();
2075
2175
  /**
2076
- * M4 Phase 3: made public (was private through M3) so an embedder can call
2077
- * it directly from its own operator-facing surface there is no
2078
- * bearer-authed HTTP route for this on `http.ts`'s own app (see
2079
- * `UnknownTaskError`'s own doc comment for why, and
2080
- * `examples/basic/server.ts`'s `/api/tasks/:taskId/approve` for the
2081
- * intended shape of that embedder-built surface). See this file's own
2082
- * `UnknownTaskError`/`TaskNotAwaitingApprovalError` doc comments for why
2083
- * the two failure modes are now distinct typed errors rather than a
2084
- * single generic `Error`. Every thrown message's TEXT is byte-for-byte
2085
- * unchanged from M2/M3 — only the error's type changed (this is still also
2086
- * reachable via `TaskHandle.approve()`, unaffected).
2176
+ * Mint a single-use code bound to `claims`. Claims are REQUIRED a
2177
+ * claimless mint is a compile error, and (for a JS caller, or a claims
2178
+ * object assembled from untyped config) a runtime {@link TypeError}. There
2179
+ * is no default tenant and no default product: a device with no tenant
2180
+ * must be inexpressible, so the failure happens here, at the mint, rather
2181
+ * than being filled in downstream.
2087
2182
  */
2183
+ createPairingCode(claims) {
2184
+ const validated = validatePairingCodeClaims(claims);
2185
+ const code = generatePairingCode();
2186
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
2187
+ this.codes.set(code, { code, claims: validated, expiresAt, used: false });
2188
+ return { code, expiresAt: new Date(expiresAt).toISOString() };
2189
+ }
2088
2190
  /**
2089
- * M5 (approval targeting, docs/protocol.md §5.3): `opts.approvalId`
2090
- * targets a SPECIFIC pending approval rather than "whichever one is
2091
- * currently pending" (the pre-M5 default, unchanged when `opts` is
2092
- * omitted). Validated FIRST, before any state change or wire send: if
2093
- * `opts.approvalId` is supplied and this hub has a recorded
2094
- * `pendingApprovalId` for `taskId` that DIFFERS, throws
2095
- * {@link StaleApprovalError} — no transition, no `task.approve` sent. If
2096
- * this hub never recorded a `pendingApprovalId` (a legacy daemon that
2097
- * never reported one), the call proceeds untargeted exactly as before.
2098
- * The outgoing `task.approve` carries `approvalId`: the caller-supplied
2099
- * one if given, else this hub's own recorded one, else omitted entirely
2100
- * (legacy wire shape) — so the daemon can apply its own exact-match check
2101
- * whenever this server has an id to offer at all.
2191
+ * Validate and consume a pairing code, returning the {@link PairingCodeClaims}
2192
+ * it was minted with. Throws {@link PairingCodeInvalidError} if the code is
2193
+ * unknown, expired, or already used — callers (the HTTP handler) map that to
2194
+ * a 401. Single-use is what makes the caller's "redeem, then register the
2195
+ * device row with these claims" sequence safe: a second redeem of the same
2196
+ * code can never reach the registration step at all.
2102
2197
  */
2103
- async approveTask(taskId, opts) {
2104
- const record = this.taskStore.get(taskId);
2105
- if (!record) throw new UnknownTaskError(taskId);
2106
- if (record.state !== "AwaitApproval") {
2107
- throw new TaskNotAwaitingApprovalError(taskId, record.state, "approve");
2198
+ redeemPairingCode(code) {
2199
+ const record = this.codes.get(code);
2200
+ if (!record) {
2201
+ throw new PairingCodeInvalidError("unknown code");
2108
2202
  }
2109
- if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
2110
- throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
2203
+ if (record.used) {
2204
+ throw new PairingCodeInvalidError("code already used");
2111
2205
  }
2112
- this.applyOrFail(taskId, "Running", {});
2113
- if (record.deviceId) {
2114
- const approvalId = opts?.approvalId ?? record.pendingApprovalId;
2115
- this.sendToDevice(record.deviceId, "task.approve", { approvalId }, { taskId });
2206
+ if (Date.now() > record.expiresAt) {
2207
+ throw new PairingCodeInvalidError("code expired");
2116
2208
  }
2209
+ record.used = true;
2210
+ return record.claims;
2117
2211
  }
2118
- /**
2119
- * M4 Phase 3: made public — see {@link ConnectionHub.approveTask}'s own
2120
- * doc comment for the full rationale (identical reasoning applies here).
2121
- * M5: same `opts.approvalId` targeting semantics as `approveTask` above —
2122
- * see that method's own doc comment.
2123
- */
2124
- async rejectTask(taskId, reason, opts) {
2125
- const record = this.taskStore.get(taskId);
2126
- if (!record) throw new UnknownTaskError(taskId);
2127
- if (record.state !== "AwaitApproval") {
2128
- throw new TaskNotAwaitingApprovalError(taskId, record.state, "reject");
2212
+ };
2213
+ function validatePairingCodeClaims(claims) {
2214
+ if (typeof claims !== "object" || claims === null) {
2215
+ throw new TypeError("createPairingCode requires { tenantId, productId } claims");
2216
+ }
2217
+ const { tenantId, productId } = claims;
2218
+ const tenantResult = PairResponseTenantIdSchema.safeParse(tenantId);
2219
+ if (!tenantResult.success) {
2220
+ throw new TypeError("createPairingCode requires a valid bounded tenantId");
2221
+ }
2222
+ if (typeof productId !== "string" || productId.length === 0) {
2223
+ throw new TypeError("createPairingCode requires a non-empty productId");
2224
+ }
2225
+ return { tenantId: tenantResult.data, productId };
2226
+ }
2227
+
2228
+ // src/http.ts
2229
+ async function readJsonBody(c) {
2230
+ try {
2231
+ return await c.req.json();
2232
+ } catch {
2233
+ return void 0;
2234
+ }
2235
+ }
2236
+ function buildHonoApp(deps) {
2237
+ const app = new Hono();
2238
+ const serverStartedAtMs = Date.now();
2239
+ if (deps.healthzRoute) {
2240
+ app.get("/healthz", (c) => c.json({ ok: true, uptimeMs: Date.now() - serverStartedAtMs }, 200));
2241
+ }
2242
+ app.post(BYOK_PAIR_PATH, async (c) => {
2243
+ const parsed = PairRequestSchema.safeParse(await readJsonBody(c));
2244
+ if (!parsed.success) {
2245
+ return c.json({ error: "pairingCode, deviceName, and devicePublicKey are required strings" }, 400);
2129
2246
  }
2130
- if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
2131
- throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
2247
+ const { pairingCode, deviceName, devicePublicKey } = parsed.data;
2248
+ let claims;
2249
+ try {
2250
+ claims = deps.pairing.redeemPairingCode(pairingCode);
2251
+ } catch (err) {
2252
+ if (err instanceof PairingCodeInvalidError) {
2253
+ return c.json({ error: err.message }, 401);
2254
+ }
2255
+ throw err;
2132
2256
  }
2133
- this.applyOrFail(taskId, "Failed", {
2134
- result: { state: "Failed", reason: reason ?? "approval rejected", retryable: false }
2257
+ const deviceId = generateDeviceId();
2258
+ deps.devices.register({
2259
+ tenantId: claims.tenantId,
2260
+ productId: claims.productId,
2261
+ deviceId,
2262
+ deviceName,
2263
+ devicePublicKey
2135
2264
  });
2136
- if (record.deviceId) {
2137
- const approvalId = opts?.approvalId ?? record.pendingApprovalId;
2138
- this.sendToDevice(record.deviceId, "task.reject", { reason, approvalId }, { taskId });
2265
+ const device = deps.devices.get(claims.tenantId, deviceId);
2266
+ if (device === void 0) {
2267
+ throw new Error("paired device row was not persisted");
2139
2268
  }
2140
- }
2141
- /**
2142
- * S0 (GAP-002): a task-level gate, evaluated in full before any envelope is
2143
- * built — see {@link SteerRejectedError} for the gap this closes and why an
2144
- * unknown capability must refuse rather than proceed. Order matters:
2145
- *
2146
- * 1. unknown task — unchanged pre-S0 `Error` (this is not a steer-policy
2147
- * decision, and `TaskHandle.steer` can only be reached with a taskId
2148
- * this hub minted, so it's a programming error, not an operator one);
2149
- * 2. terminal (`Complete`/`Failed`/`Cancelled`) -> `task_terminal`,
2150
- * checked BEFORE the `Running` check so a steer racing a terminal
2151
- * transition always resolves terminal-first;
2152
- * 3. not `Running` (`Offered`/`Claimed`/`AwaitApproval`) ->
2153
- * `task_not_running`;
2154
- * 4. the claim-time snapshot does not positively say `steer: true` ->
2155
- * `steer_unsupported_runtime`, including when there is no snapshot at
2156
- * all (fail-closed);
2157
- * 5. only then, the pre-existing device-liveness check and the send.
2158
- *
2159
- * Step 4 reads `TaskSnapshot.claimedRuntimeCapabilities` the per-runtime,
2160
- * per-task value frozen at claim time from the claiming adapter's own
2161
- * `task.claim.capabilities` — and reads NO connection state whatsoever:
2162
- * not {@link getDeviceCapabilities}, not `ConnectionState.runtimes`, and
2163
- * with no fallback to either when the snapshot is absent. See
2164
- * {@link SteerRejectedError} for why a connection-sourced input is wrong
2165
- * in scope (it describes a daemon build, not this task's runtime).
2166
- */
2167
- async steerTask(taskId, text) {
2168
- const record = this.taskStore.get(taskId);
2169
- if (!record) throw new Error(`unknown taskId: ${taskId}`);
2170
- if (isTerminal(record.state)) {
2171
- throw new SteerRejectedError(taskId, "task_terminal", record.state, record.claimedRuntime);
2269
+ const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
2270
+ deviceId: device.deviceId,
2271
+ tenantId: device.tenantId,
2272
+ productId: device.productId
2273
+ });
2274
+ const response = PairResponseSchema.parse({
2275
+ deviceId: device.deviceId,
2276
+ accessToken,
2277
+ refreshHint: expiresAt,
2278
+ tenantId: device.tenantId
2279
+ });
2280
+ return c.json(response, 200);
2281
+ });
2282
+ app.post(BYOK_CHALLENGE_PATH, async (c) => {
2283
+ const parsed = ChallengeRequestSchema.safeParse(await readJsonBody(c));
2284
+ if (!parsed.success) return c.json({ error: "deviceId is required" }, 400);
2285
+ const { deviceId } = parsed.data;
2286
+ const device = deps.devices.resolveByDeviceId(deviceId);
2287
+ if (!device || device.revoked) {
2288
+ return c.json({ error: "unknown or revoked device" }, 401);
2289
+ }
2290
+ const nonce = deps.nonces.issue(deviceId);
2291
+ const response = { nonce };
2292
+ return c.json(response, 200);
2293
+ });
2294
+ app.post(BYOK_TOKEN_PATH, async (c) => {
2295
+ const parsed = TokenRequestSchema.safeParse(await readJsonBody(c));
2296
+ if (!parsed.success) return c.json({ error: "deviceId, nonce, and signature are required" }, 400);
2297
+ const { deviceId, nonce, signature } = parsed.data;
2298
+ const device = deps.devices.resolveByDeviceId(deviceId);
2299
+ if (!device || device.revoked) {
2300
+ return c.json({ error: "unknown or revoked device" }, 401);
2301
+ }
2302
+ if (!deps.nonces.validate(deviceId, nonce)) {
2303
+ return c.json({ error: "invalid, expired, or already-used nonce" }, 401);
2304
+ }
2305
+ if (!verifyNonceSignature(device.devicePublicKey, nonce, signature)) {
2306
+ return c.json({ error: "invalid signature" }, 401);
2307
+ }
2308
+ deps.nonces.markUsed(nonce);
2309
+ const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
2310
+ deviceId: device.deviceId,
2311
+ tenantId: device.tenantId,
2312
+ productId: device.productId
2313
+ });
2314
+ const response = { accessToken, expiresAt };
2315
+ return c.json(response, 200);
2316
+ });
2317
+ app.post(BYOK_BLOBS_PATH, async (c) => {
2318
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2319
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2320
+ const parsed = CreateBlobRequestSchema.safeParse(await readJsonBody(c));
2321
+ if (!parsed.success) return c.json({ error: "size, contentType, and contentHash are required" }, 400);
2322
+ if (parsed.data.size > deps.maxBlobSizeBytes) {
2323
+ return c.json({ error: `blob exceeds max size of ${deps.maxBlobSizeBytes} bytes` }, 413);
2324
+ }
2325
+ const reservationId = c.req.header("idempotency-key");
2326
+ if (!reservationId || reservationId.length > 200) {
2327
+ return c.json({ error: "Idempotency-Key header is required" }, 400);
2328
+ }
2329
+ const blobId = reservationBlobId(principal.tenantId, reservationId);
2330
+ try {
2331
+ const created = await deps.blobStore.createUpload(parsed.data, blobId);
2332
+ const response = created;
2333
+ return c.json(response, 200);
2334
+ } catch (error) {
2335
+ if (error instanceof BlobDeclarationConflictError) {
2336
+ return c.json({ error: "storage_integrity_mismatch" }, 422);
2337
+ }
2338
+ throw error;
2172
2339
  }
2173
- if (record.state !== "Running") {
2174
- throw new SteerRejectedError(taskId, "task_not_running", record.state, record.claimedRuntime);
2340
+ });
2341
+ app.post(BYOK_BLOB_FINALIZE_ROUTE, async (c) => {
2342
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2343
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2344
+ const reservationId = c.req.header("idempotency-key");
2345
+ if (!reservationId || reservationId.length > 200) {
2346
+ return c.json({ error: "Idempotency-Key header is required" }, 400);
2175
2347
  }
2176
- if (record.claimedRuntimeCapabilities?.steer !== true) {
2177
- throw new SteerRejectedError(taskId, "steer_unsupported_runtime", record.state, record.claimedRuntime);
2348
+ const blobId = c.req.param("id");
2349
+ if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
2350
+ return c.json({ error: "storage_integrity_mismatch" }, 422);
2178
2351
  }
2179
- if (!record.deviceId || !this.connections.get(record.deviceId)?.connected) {
2180
- throw new Error(`device for task ${taskId} is not connected`);
2352
+ if (!await deps.blobStore.exists(blobId)) {
2353
+ return c.json({ error: "storage_reservation_not_found" }, 404);
2181
2354
  }
2182
- this.sendToDevice(record.deviceId, "task.steer", { text }, { taskId });
2183
- }
2184
- pickFirstConnectedDevice(requiredToolsets) {
2185
- for (const [deviceId, conn] of this.connections) {
2186
- if (!conn.connected) continue;
2187
- if (requiredToolsets === void 0) return deviceId;
2188
- if (!conn.capabilities?.includes("toolset-selection")) continue;
2189
- if (conn.configuredToolsets === void 0) continue;
2190
- const configured = new Set(conn.configuredToolsets);
2191
- if (requiredToolsets.every((toolsetId) => configured.has(toolsetId))) return deviceId;
2355
+ return c.body(null, 204);
2356
+ });
2357
+ app.get(BYOK_BLOB_URL_ROUTE, async (c) => {
2358
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2359
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2360
+ const downloadUrl = await deps.blobStore.getDownloadUrl(c.req.param("id"));
2361
+ if (!downloadUrl) return c.json({ error: "blob not found" }, 404);
2362
+ const response = { downloadUrl };
2363
+ return c.json(response, 200);
2364
+ });
2365
+ app.put(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
2366
+ const blobId = c.req.param("id");
2367
+ const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
2368
+ if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "put", sig, exp)) {
2369
+ return c.json({ error: "invalid or expired signature" }, 401);
2192
2370
  }
2193
- return void 0;
2194
- }
2195
- // ---------------------------------------------------------------------
2196
- // outbound envelope delivery + per-device seq/redelivery bookkeeping (§1.2, §9)
2197
- // ---------------------------------------------------------------------
2198
- /**
2199
- * Build a server -> daemon envelope with a fresh per-device `seq`, retain
2200
- * it in that device's outbox ring buffer, and deliver it now if a live
2201
- * transport is available (WS send, or wake a pending long-poll).
2202
- *
2203
- * `opts`'s type mirrors `createEnvelope`'s own per-type conditional
2204
- * requiredness (finding F1) minus `seq` (computed fresh right here on
2205
- * every call, never caller-supplied) — so every one of this method's 6
2206
- * callers below must supply `taskId` for the 5 types that need it
2207
- * (everything except `conn.ack`), same as calling `createEnvelope`
2208
- * directly would require.
2209
- */
2210
- sendToDevice(deviceId, type, payload, opts) {
2211
- this.envelopesOutCount++;
2212
- const outbox = this.getOrCreateOutbox(deviceId);
2213
- const seq = outbox.nextSeq++;
2214
- const combinedOpts = { ...opts, seq };
2215
- const envelope = createEnvelope(type, payload, combinedOpts);
2216
- const taskId = opts.taskId;
2217
- const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
2218
- const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read";
2219
- outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
2220
- if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
2221
- this.deliverToDevice(deviceId, envelope);
2222
- return envelope;
2223
- }
2224
- deliverToDevice(deviceId, envelope) {
2225
- const conn = this.connections.get(deviceId);
2226
- if (conn?.connected && conn.ws) {
2227
- conn.ws.send(encodeEnvelope(envelope));
2371
+ const data = Buffer.from(await c.req.arrayBuffer());
2372
+ const result = await deps.blobStore.writeContent(blobId, data);
2373
+ if (!result.ok) return c.json({ error: result.reason }, 422);
2374
+ return c.body(null, 204);
2375
+ });
2376
+ app.get(BYOK_BLOB_CONTENT_ROUTE, async (c) => {
2377
+ const blobId = c.req.param("id");
2378
+ const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
2379
+ if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "get", sig, exp)) {
2380
+ return c.json({ error: "invalid or expired signature" }, 401);
2228
2381
  }
2229
- this.settleLongPollWaiter(deviceId);
2230
- }
2231
- /**
2232
- * Retained envelopes for `deviceId` with `seq > cursor` that still belong
2233
- * to a non-terminal task — OR are explicitly exempted from that filter
2234
- * (`redeliverThroughTerminal`, N1/F4: `task.cancel`/`task.reject`) — in
2235
- * `seq` order. The `seq > cursor` bound is what naturally stops an
2236
- * exempted entry from redelivering forever: once the daemon acks it (its
2237
- * reported cursor advances past that `seq`), it no longer qualifies here
2238
- * on any future reconnect/poll.
2239
- */
2240
- collectRelevant(deviceId, cursor) {
2241
- const outbox = this.outboxes.get(deviceId);
2242
- if (!outbox) return [];
2243
- return outbox.ring.filter(
2244
- (entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
2245
- ).map((entry) => entry.envelope);
2246
- }
2247
- isTaskTerminal(taskId) {
2248
- const record = this.taskStore.get(taskId);
2249
- return !record || isTerminal(record.state);
2250
- }
2251
- /** The highest `seq` assigned to `deviceId` so far — the redelivery cursor to hand back on a poll/reconnect. */
2252
- currentCursor(deviceId) {
2253
- const outbox = this.outboxes.get(deviceId);
2254
- return outbox ? outbox.nextSeq - 1 : 0;
2255
- }
2256
- getOrCreateOutbox(deviceId) {
2257
- let outbox = this.outboxes.get(deviceId);
2258
- if (!outbox) {
2259
- outbox = { nextSeq: 1, ring: [] };
2260
- this.outboxes.set(deviceId, outbox);
2382
+ const content = await deps.blobStore.readContent(blobId);
2383
+ if (!content) return c.json({ error: "blob not found" }, 404);
2384
+ return c.body(new Uint8Array(content.data), 200, { "content-type": content.contentType });
2385
+ });
2386
+ app.get(BYOK_EVENTS_PATH, async (c) => {
2387
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2388
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2389
+ const cursorRaw = c.req.query("cursor");
2390
+ let cursor = 0;
2391
+ if (cursorRaw !== void 0) {
2392
+ const parsedCursor = Number(cursorRaw);
2393
+ if (!Number.isInteger(parsedCursor) || parsedCursor < 0) return c.json({ error: "invalid cursor" }, 400);
2394
+ cursor = parsedCursor;
2261
2395
  }
2262
- return outbox;
2263
- }
2264
- // ---------------------------------------------------------------------
2265
- // read-only accessors backing the public `machines` / `tasks` API
2266
- // ---------------------------------------------------------------------
2267
- listMachines() {
2268
- return this.devices.list().map(({ deviceId, deviceName }) => {
2269
- const conn = this.connections.get(deviceId);
2270
- return {
2271
- deviceId,
2272
- deviceName,
2273
- connected: conn?.connected ?? false,
2274
- lastSeen: conn?.lastSeen,
2275
- ...conn?.clientVersion === void 0 ? {} : { clientVersion: conn.clientVersion },
2276
- runtimes: conn?.runtimes,
2277
- configuredToolsets: conn?.configuredToolsets ? [...conn.configuredToolsets] : void 0
2278
- };
2279
- });
2280
- }
2281
- /**
2282
- * M5 (approval targeting, hello-capability plumbing): the capability flags
2283
- * `deviceId`'s CURRENT connection advertised in its `conn.hello` —
2284
- * `undefined` if this hub has no connection state for the device at all,
2285
- * or one that never had capabilities recorded (a pre-M5 daemon, or a
2286
- * device this hub only ever saw over long-poll with no prior WS hello —
2287
- * see `ConnectionState.capabilities`'s own doc comment). Read fresh from
2288
- * live connection state, mirroring `listMachines()`'s own convention; an
2289
- * embedder can use this to distinguish a targeting-capable device from a
2290
- * legacy one for its own observability/UI purposes (see `version.ts`'s
2291
- * `approval-targeting` flag doc comment for why this is informational
2292
- * only, never a correctness gate).
2293
- */
2294
- getDeviceCapabilities(deviceId) {
2295
- return this.connections.get(deviceId)?.capabilities;
2296
- }
2297
- hasDeviceCapabilities(deviceId, required) {
2298
- const advertised = this.getDeviceCapabilities(deviceId);
2299
- return advertised !== void 0 && required.every((capability) => advertised.includes(capability));
2300
- }
2301
- getAgentEgressReceipt(deviceId, eventId) {
2302
- return this.agentEgressReceipts.get(this.agentEgressReceiptKey(deviceId, eventId));
2303
- }
2304
- getTask(taskId) {
2305
- return this.taskStore.get(taskId);
2306
- }
2307
- listTasks() {
2308
- return this.taskStore.list();
2309
- }
2310
- // ---------------------------------------------------------------------
2311
- // observability (M4 Phase 4, part B.1) — in-process only; see
2312
- // `types.ts`'s `HubStats`/`CreateByokServerOptions.healthzRoute` doc
2313
- // comments for why this is never exposed over HTTP by this SDK itself.
2314
- // ---------------------------------------------------------------------
2315
- /**
2316
- * A plain, serializable snapshot of this hub's current state, derived from
2317
- * existing structures (`connections`, `taskStore`) plus the small counters
2318
- * this file already maintains for exactly this purpose — no new
2319
- * bookkeeping structures beyond those counters. See {@link HubStats}
2320
- * (`types.ts`) for the full field-by-field contract.
2321
- */
2322
- stats() {
2323
- const taskCountsByState = Object.fromEntries(TASK_STATES.map((state) => [state, 0]));
2324
- for (const record of this.taskStore.list()) {
2325
- taskCountsByState[record.state]++;
2396
+ const result = await deps.hub.pollEvents(principal.deviceId, cursor, deps.longPollHoldMs);
2397
+ const response = { ...result, capabilities: [...CAPABILITY_FLAGS] };
2398
+ return c.json(response, 200);
2399
+ });
2400
+ app.post(BYOK_MESSAGES_PATH, async (c) => {
2401
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2402
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2403
+ const parsed = MessagesSendRequestSchema.safeParse(await readJsonBody(c));
2404
+ if (!parsed.success) return c.json({ error: "messages must be an array of envelopes" }, 400);
2405
+ let accepted = 0;
2406
+ let rejected = 0;
2407
+ for (const envelope of parsed.data.messages) {
2408
+ const result = deps.hub.handleInbound(principal.deviceId, envelope, principal.productId);
2409
+ if (result === "rate_limited") {
2410
+ return c.json({ error: "rate limit exceeded" }, 429);
2411
+ }
2412
+ if (result === "rejected") rejected++;
2413
+ else accepted++;
2326
2414
  }
2327
- let connectedDeviceCount = 0;
2328
- for (const conn of this.connections.values()) {
2329
- if (conn.connected) connectedDeviceCount++;
2415
+ const response = rejected > 0 ? { accepted, rejected } : { accepted };
2416
+ return c.json(response, 200);
2417
+ });
2418
+ app.put(BYOK_AGENT_HOME_PROJECTION_COMPLETION_ROUTE, async (c) => {
2419
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
2420
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
2421
+ const parsed = AgentHomeProjectionCompletionRequestSchema.safeParse(await readJsonBody(c));
2422
+ if (!parsed.success || parsed.data.requestId !== c.req.param("requestId")) {
2423
+ return c.json({ error: "invalid Agent-home projection completion" }, 422);
2330
2424
  }
2331
- return {
2332
- connectedDeviceCount,
2333
- taskCountsByState,
2334
- envelopesIn: this.envelopesInCount,
2335
- envelopesOut: this.envelopesOutCount,
2336
- dedupDrops: this.dedupDropCount,
2337
- rateLimitEvents: this.rateLimitEventCount,
2338
- uptimeMs: Date.now() - this.startedAtMs
2339
- };
2340
- }
2341
- };
2425
+ try {
2426
+ return c.json(deps.hub.completeAgentHomeProjection(principal.deviceId, parsed.data), 200);
2427
+ } catch (error) {
2428
+ if (error instanceof AgentHomeProjectionCompletionError) {
2429
+ if (error.code === "not_found") return c.json({ error: error.message }, 404);
2430
+ if (error.code === "conflict") return c.json({ error: error.message }, 409);
2431
+ return c.json({ error: error.message }, 422);
2432
+ }
2433
+ throw error;
2434
+ }
2435
+ });
2436
+ return app;
2437
+ }
2438
+ function reservationBlobId(tenantId, reservationId) {
2439
+ return `blob_${createHash("sha256").update(`${tenantId}\0${reservationId}`).digest("hex")}`;
2440
+ }
2441
+ function signedUrlParams(sig, expRaw) {
2442
+ if (!sig || expRaw === void 0) return {};
2443
+ const exp = Number(expRaw);
2444
+ if (!Number.isFinite(exp)) return {};
2445
+ return { sig, exp };
2446
+ }
2342
2447
  var IllegalTaskTransitionError = class extends Error {
2343
2448
  constructor(taskId, from, to) {
2344
2449
  super(`illegal task transition for ${taskId}: ${from} -> ${to}`);
@@ -3074,6 +3179,8 @@ function createByokServer(opts) {
3074
3179
  dispatch: (input) => hub.dispatch(input),
3075
3180
  dispatchFreshAgentEgress: (input) => hub.dispatchFreshAgentEgress(input),
3076
3181
  requestAgentContentRead: (input) => hub.requestAgentContentRead(input),
3182
+ enqueueAgentHomeProjection: (input) => hub.enqueueAgentHomeProjection(input),
3183
+ readAgentHomeProjection: (deviceId, requestId) => hub.readAgentHomeProjection(deviceId, requestId),
3077
3184
  tasks: {
3078
3185
  get: (taskId) => hub.getTask(taskId),
3079
3186
  list: () => hub.listTasks()
@@ -3097,6 +3204,6 @@ function createByokServer(opts) {
3097
3204
  };
3098
3205
  }
3099
3206
 
3100
- export { IllegalTaskTransitionError, InMemoryTaskStore, LocalDiskBlobStore, PairingCodeInvalidError, SqliteBlobStore, SqliteTaskStore, SqliteUnavailableError, StaleApprovalError, SteerRejectedError, createByokServer, createHmacTokenSigner };
3207
+ export { AgentHomeProjectionCompletionError, IllegalTaskTransitionError, InMemoryTaskStore, LocalDiskBlobStore, PairingCodeInvalidError, SqliteBlobStore, SqliteTaskStore, SqliteUnavailableError, StaleApprovalError, SteerRejectedError, createByokServer, createHmacTokenSigner };
3101
3208
  //# sourceMappingURL=index.js.map
3102
3209
  //# sourceMappingURL=index.js.map