@byok-sdk/server 0.7.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/hub.d.ts +17 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +1910 -1765
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +17 -1
- package/package.json +3 -3
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, 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,476 +313,166 @@ function generateDeviceId() {
|
|
|
266
313
|
function generateTaskId() {
|
|
267
314
|
return `task_${randomUUID()}`;
|
|
268
315
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
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 (
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
function
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
382
|
-
});
|
|
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);
|
|
407
|
-
}
|
|
408
|
-
if (!verifyNonceSignature(device.devicePublicKey, nonce, signature)) {
|
|
409
|
-
return c.json({ error: "invalid signature" }, 401);
|
|
410
|
-
}
|
|
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
|
|
416
|
-
});
|
|
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);
|
|
427
|
-
}
|
|
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);
|
|
431
|
-
}
|
|
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);
|
|
440
|
-
}
|
|
441
|
-
throw error;
|
|
442
|
-
}
|
|
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);
|
|
450
|
-
}
|
|
451
|
-
const blobId = c.req.param("id");
|
|
452
|
-
if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
|
|
453
|
-
return c.json({ error: "storage_integrity_mismatch" }, 422);
|
|
454
|
-
}
|
|
455
|
-
if (!await deps.blobStore.exists(blobId)) {
|
|
456
|
-
return c.json({ error: "storage_reservation_not_found" }, 404);
|
|
457
|
-
}
|
|
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);
|
|
473
|
-
}
|
|
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);
|
|
484
|
-
}
|
|
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;
|
|
498
|
-
}
|
|
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++;
|
|
517
|
-
}
|
|
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();
|
|
542
|
-
}
|
|
543
|
-
close() {
|
|
544
|
-
if (this.closed) return;
|
|
545
|
-
this.closed = true;
|
|
546
|
-
this.wake();
|
|
547
|
-
}
|
|
548
|
-
wake() {
|
|
549
|
-
const waiters = this.waiters;
|
|
550
|
-
this.waiters = [];
|
|
551
|
-
for (const resolve of waiters) resolve();
|
|
552
|
-
}
|
|
553
|
-
waitForMore() {
|
|
554
|
-
return new Promise((resolve) => this.waiters.push(resolve));
|
|
555
|
-
}
|
|
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
|
-
};
|
|
577
|
-
}
|
|
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
|
-
/**
|
|
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.
|
|
599
|
-
*/
|
|
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}`);
|
|
611
|
-
}
|
|
612
|
-
if (!Number.isFinite(burst) || burst < 1) {
|
|
613
|
-
throw new TypeError(`RateLimiter: burst must be a finite number >= 1, got ${burst}`);
|
|
614
|
-
}
|
|
615
|
-
if (!Number.isFinite(maxTrackedDevices) || maxTrackedDevices < 1) {
|
|
616
|
-
throw new TypeError(`RateLimiter: maxTrackedDevices must be a finite number >= 1, got ${maxTrackedDevices}`);
|
|
617
|
-
}
|
|
618
|
-
this.messagesPerSecond = messagesPerSecond;
|
|
619
|
-
this.burst = burst;
|
|
620
|
-
this.maxTrackedDevices = maxTrackedDevices;
|
|
621
|
-
this.idleEvictionThresholdMs = burst / messagesPerSecond * 1e3;
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
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.
|
|
628
|
-
*/
|
|
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
|
-
}
|
|
643
|
-
}
|
|
644
|
-
if (bucket.tokens < 1) return false;
|
|
645
|
-
bucket.tokens -= 1;
|
|
646
|
-
return true;
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
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.
|
|
655
|
-
*/
|
|
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
|
-
}
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
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.
|
|
696
|
-
*/
|
|
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
|
-
}
|
|
706
|
-
}
|
|
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;
|
|
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;
|
|
739
476
|
}
|
|
740
477
|
}
|
|
741
478
|
var UnknownTaskError = class extends Error {
|
|
@@ -794,6 +531,14 @@ var SteerRejectedError = class extends Error {
|
|
|
794
531
|
state;
|
|
795
532
|
runtime;
|
|
796
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
|
+
};
|
|
797
542
|
var ConnectionHub = class {
|
|
798
543
|
constructor(taskStore, devices, taskLeaseMs, rateLimiter = new RateLimiter()) {
|
|
799
544
|
this.taskStore = taskStore;
|
|
@@ -821,6 +566,10 @@ var ConnectionHub = class {
|
|
|
821
566
|
agentContentReadRequests = /* @__PURE__ */ new Map();
|
|
822
567
|
/** Content-free explicit-read audit facts keyed by exact authenticated device/request identity. */
|
|
823
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();
|
|
824
573
|
/**
|
|
825
574
|
* Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
|
|
826
575
|
* reaper's condition (c), see the "task-lease reaper" section below. Reset
|
|
@@ -849,1459 +598,1852 @@ var ConnectionHub = class {
|
|
|
849
598
|
* Coalescing state only; {@link rateLimitEventCount} still counts every
|
|
850
599
|
* single hit regardless of what this suppresses.
|
|
851
600
|
*/
|
|
852
|
-
rateLimitEventEmittedFor = /* @__PURE__ */ new Set();
|
|
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
|
|
639
|
+
});
|
|
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));
|
|
666
|
+
}
|
|
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
|
+
// ---------------------------------------------------------------------
|
|
853
703
|
/**
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
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}).
|
|
857
710
|
*/
|
|
858
|
-
|
|
859
|
-
|
|
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) };
|
|
717
|
+
}
|
|
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 });
|
|
725
|
+
});
|
|
860
726
|
}
|
|
861
|
-
/**
|
|
862
|
-
|
|
863
|
-
|
|
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;
|
|
748
|
+
}
|
|
749
|
+
if (wasFreshlyConnected) {
|
|
750
|
+
this.serverEvents.push({ kind: "device.connected", deviceId, at });
|
|
751
|
+
}
|
|
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) });
|
|
864
760
|
}
|
|
865
761
|
// ---------------------------------------------------------------------
|
|
866
|
-
//
|
|
762
|
+
// inbound envelopes from a connected daemon
|
|
867
763
|
// ---------------------------------------------------------------------
|
|
868
764
|
/**
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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:
|
|
871
769
|
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
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.
|
|
879
801
|
*/
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
this.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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";
|
|
813
|
+
}
|
|
814
|
+
if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
|
|
815
|
+
this.dedupDropCount++;
|
|
816
|
+
return "duplicate";
|
|
817
|
+
}
|
|
818
|
+
this.registerLongPollHello(deviceId, payload);
|
|
819
|
+
return "accepted";
|
|
820
|
+
}
|
|
821
|
+
if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
|
|
822
|
+
return "rejected";
|
|
823
|
+
}
|
|
824
|
+
if (envelope.type === "agent.egress.reliable") {
|
|
825
|
+
return this.handleAgentEgressReliable(deviceId, envelope.payload);
|
|
826
|
+
}
|
|
827
|
+
if (envelope.type === "agent.content.receipt") {
|
|
828
|
+
return this.handleAgentContentReceipt(deviceId, envelope.payload);
|
|
829
|
+
}
|
|
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";
|
|
836
|
+
}
|
|
837
|
+
if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
|
|
838
|
+
this.dedupDropCount++;
|
|
839
|
+
return "duplicate";
|
|
840
|
+
}
|
|
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";
|
|
860
|
+
}
|
|
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";
|
|
884
|
+
}
|
|
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";
|
|
894
|
+
}
|
|
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
|
+
);
|
|
893
909
|
}
|
|
894
|
-
|
|
910
|
+
sendAgentContentReceiptAck(deviceId, receipt) {
|
|
895
911
|
this.sendToDevice(
|
|
896
912
|
deviceId,
|
|
897
|
-
"
|
|
913
|
+
"agent.egress.ack",
|
|
898
914
|
{
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
902
921
|
},
|
|
903
922
|
{}
|
|
904
|
-
// conn.ack needs neither taskId nor sessionRef
|
|
905
923
|
);
|
|
906
924
|
}
|
|
925
|
+
agentEgressReceiptKey(deviceId, eventId) {
|
|
926
|
+
return `${deviceId}\0${eventId}`;
|
|
927
|
+
}
|
|
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();
|
|
937
|
+
}
|
|
907
938
|
/**
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
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.
|
|
911
969
|
*/
|
|
912
|
-
|
|
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 });
|
|
976
|
+
}
|
|
913
977
|
const conn = this.connections.get(deviceId);
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
conn.ws.send(encodeEnvelope(envelope));
|
|
978
|
+
if (conn?.ws) {
|
|
979
|
+
conn.ws.close(1008, "rate limit exceeded");
|
|
917
980
|
}
|
|
918
981
|
}
|
|
919
982
|
/**
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
|
|
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).
|
|
925
1008
|
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
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).
|
|
940
1025
|
*/
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1026
|
+
dispatchToHandler(deviceId, taskId, envelope) {
|
|
1027
|
+
const record = this.taskStore.get(taskId);
|
|
1028
|
+
if (record && !isTerminal(record.state)) {
|
|
1029
|
+
this.recordTaskActivity(taskId);
|
|
1030
|
+
}
|
|
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;
|
|
1064
|
+
}
|
|
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());
|
|
950
1069
|
}
|
|
951
|
-
// ---------------------------------------------------------------------
|
|
952
|
-
// long-poll fallback (§8) — GET /byok/events, called from http.ts
|
|
953
|
-
// ---------------------------------------------------------------------
|
|
954
1070
|
/**
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
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.
|
|
1101
|
+
*/
|
|
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;
|
|
1108
|
+
}
|
|
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
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
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.
|
|
1124
|
+
*/
|
|
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", {});
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
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.
|
|
961
1136
|
*/
|
|
962
|
-
|
|
963
|
-
this.
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if (
|
|
967
|
-
|
|
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;
|
|
968
1144
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
this.longPollWaiters.set(deviceId, { cursor, resolve, timer });
|
|
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 }
|
|
976
1151
|
});
|
|
977
1152
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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;
|
|
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;
|
|
999
1160
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
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 });
|
|
1002
1165
|
}
|
|
1003
1166
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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:
|
|
1020
|
-
*
|
|
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.
|
|
1047
|
-
*
|
|
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.
|
|
1052
|
-
*/
|
|
1053
|
-
handleInbound(deviceId, envelope, authenticatedProductId) {
|
|
1054
|
-
this.envelopesInCount++;
|
|
1055
|
-
if (!this.rateLimiter.consume(deviceId)) {
|
|
1056
|
-
this.handleRateLimited(deviceId);
|
|
1057
|
-
return "rate_limited";
|
|
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;
|
|
1058
1174
|
}
|
|
1059
|
-
this.
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1175
|
+
const runtime = this.runtimes.get(taskId);
|
|
1176
|
+
if (!runtime) return;
|
|
1177
|
+
runtime.queue.push({ kind: "artifact", artifact: payload });
|
|
1178
|
+
}
|
|
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 });
|
|
1068
1186
|
}
|
|
1069
|
-
|
|
1070
|
-
return "accepted";
|
|
1187
|
+
return;
|
|
1071
1188
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
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 });
|
|
1193
|
+
}
|
|
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;
|
|
1074
1201
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
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 });
|
|
1220
|
+
}
|
|
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;
|
|
1077
1228
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1229
|
+
const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
|
|
1230
|
+
this.applyOrFail(taskId, "Failed", { result });
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
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.
|
|
1241
|
+
*/
|
|
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;
|
|
1080
1250
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1251
|
+
this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
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.
|
|
1306
|
+
*/
|
|
1307
|
+
onApprovalResolved(taskId, payload) {
|
|
1083
1308
|
const record = this.taskStore.get(taskId);
|
|
1084
|
-
if (record
|
|
1085
|
-
|
|
1086
|
-
|
|
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;
|
|
1087
1316
|
}
|
|
1088
|
-
if (
|
|
1089
|
-
|
|
1090
|
-
|
|
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;
|
|
1091
1322
|
}
|
|
1092
|
-
this.
|
|
1093
|
-
|
|
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
|
+
});
|
|
1094
1336
|
}
|
|
1337
|
+
// ---------------------------------------------------------------------
|
|
1338
|
+
// transition helpers — the single place "illegal transition" is handled
|
|
1339
|
+
// ---------------------------------------------------------------------
|
|
1095
1340
|
/**
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
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:
|
|
1348
|
+
*
|
|
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.
|
|
1099
1364
|
*/
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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";
|
|
1111
|
-
}
|
|
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";
|
|
1121
|
-
}
|
|
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";
|
|
1135
|
-
}
|
|
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();
|
|
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;
|
|
1188
1370
|
}
|
|
1189
1371
|
/**
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
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.
|
|
1197
|
-
*
|
|
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.
|
|
1211
|
-
*
|
|
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.
|
|
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.
|
|
1220
1375
|
*/
|
|
1221
|
-
|
|
1222
|
-
this.
|
|
1223
|
-
if (!
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
}
|
|
1228
|
-
const conn = this.connections.get(deviceId);
|
|
1229
|
-
if (conn?.ws) {
|
|
1230
|
-
conn.ws.close(1008, "rate limit exceeded");
|
|
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;
|
|
1231
1382
|
}
|
|
1383
|
+
this.forceFailOrDrop(taskId, `illegal transition ${record.state} -> ${target}`);
|
|
1232
1384
|
}
|
|
1233
1385
|
/**
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
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.
|
|
1392
|
+
*
|
|
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.
|
|
1409
|
+
*
|
|
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.
|
|
1421
|
+
*
|
|
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.
|
|
1238
1428
|
*/
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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);
|
|
1250
|
-
}
|
|
1251
|
-
return false;
|
|
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;
|
|
1252
1434
|
}
|
|
1253
1435
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
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).
|
|
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.
|
|
1276
1440
|
*/
|
|
1277
|
-
|
|
1441
|
+
forceFailOrDrop(taskId, reason) {
|
|
1278
1442
|
const record = this.taskStore.get(taskId);
|
|
1279
|
-
if (
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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;
|
|
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;
|
|
1315
1449
|
}
|
|
1450
|
+
console.warn(`[byok/server] dropping message for ${taskId} (state ${record.state}): ${reason}`);
|
|
1316
1451
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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();
|
|
1471
|
+
}
|
|
1320
1472
|
}
|
|
1473
|
+
// ---------------------------------------------------------------------
|
|
1474
|
+
// task-lease reaper (Decision: Failed(retryable:true) on dark-device
|
|
1475
|
+
// timeout — no new task state, no new wire message)
|
|
1476
|
+
// ---------------------------------------------------------------------
|
|
1321
1477
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
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.
|
|
1326
1486
|
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
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.
|
|
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):
|
|
1338
1492
|
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
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.
|
|
1344
1530
|
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
* the
|
|
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.
|
|
1545
|
+
*
|
|
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.
|
|
1352
1567
|
*/
|
|
1353
|
-
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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);
|
|
1363
1578
|
}
|
|
1364
|
-
if (record.state === "Claimed" || record.state === "Running") return;
|
|
1365
|
-
this.applyOrFail(taskId, "Claimed", {
|
|
1366
|
-
deviceId,
|
|
1367
|
-
claimedRuntime: payload.runtime,
|
|
1368
|
-
claimedRuntimeCapabilities: payload.capabilities
|
|
1369
|
-
});
|
|
1370
1579
|
}
|
|
1371
1580
|
/**
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
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.
|
|
1375
1587
|
*/
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
if (!
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1588
|
+
deviceDarkSince(deviceId, nowMs) {
|
|
1589
|
+
const conn = this.connections.get(deviceId);
|
|
1590
|
+
if (!conn || !conn.connected) {
|
|
1591
|
+
return conn?.darkSince ?? 0;
|
|
1592
|
+
}
|
|
1593
|
+
if (conn.ws) return void 0;
|
|
1594
|
+
const lastSeenMs = Date.parse(conn.lastSeen);
|
|
1595
|
+
return nowMs - lastSeenMs >= this.taskLeaseMs ? lastSeenMs : void 0;
|
|
1382
1596
|
}
|
|
1383
|
-
/**
|
|
1384
|
-
|
|
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) {
|
|
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) {
|
|
1389
1599
|
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;
|
|
1395
|
-
}
|
|
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;
|
|
1399
|
-
}
|
|
1600
|
+
if (!record || isTerminal(record.state)) return;
|
|
1400
1601
|
this.applyOrFail(taskId, "Failed", {
|
|
1401
|
-
result: { state: "Failed", reason:
|
|
1602
|
+
result: { state: "Failed", reason: "lease-expired", retryable: true }
|
|
1402
1603
|
});
|
|
1403
1604
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
if (
|
|
1409
|
-
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
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 });
|
|
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");
|
|
1416
1611
|
}
|
|
1612
|
+
return this.dispatchInternal(input, false);
|
|
1417
1613
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
const resumed = this.resumeIfImplicitlyApproved(record);
|
|
1422
|
-
if (resumed.state !== "Running") {
|
|
1423
|
-
this.forceFailOrDrop(taskId, "task.artifact received while not Running");
|
|
1424
|
-
return;
|
|
1614
|
+
async dispatchFreshAgentEgress(input) {
|
|
1615
|
+
if (Object.prototype.hasOwnProperty.call(input, "sessionRef")) {
|
|
1616
|
+
throw new Error("fresh Agent egress dispatch must not carry sessionRef");
|
|
1425
1617
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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);
|
|
1429
1625
|
}
|
|
1430
|
-
|
|
1431
|
-
const
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
+
);
|
|
1437
1682
|
}
|
|
1438
|
-
return;
|
|
1439
1683
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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;
|
|
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
|
+
);
|
|
1452
1688
|
}
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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,
|
|
1698
|
+
deviceId,
|
|
1699
|
+
sessionRef,
|
|
1700
|
+
agentRef
|
|
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
|
|
1469
1716
|
};
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
+
);
|
|
1479
1762
|
}
|
|
1480
|
-
|
|
1481
|
-
this.applyOrFail(taskId, "Failed", { result });
|
|
1763
|
+
return this.buildTaskHandle(taskId);
|
|
1482
1764
|
}
|
|
1483
|
-
/**
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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) {
|
|
1494
|
-
const record = this.taskStore.get(taskId);
|
|
1495
|
-
if (!record) return;
|
|
1496
|
-
if (record.state === "Cancelled") return;
|
|
1497
|
-
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;
|
|
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`);
|
|
1501
1771
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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.
|
|
1557
|
-
*/
|
|
1558
|
-
onApprovalResolved(taskId, payload) {
|
|
1559
|
-
const record = this.taskStore.get(taskId);
|
|
1560
|
-
if (!record) return;
|
|
1561
|
-
if (record.state === "Running") return;
|
|
1562
|
-
if (record.state !== "AwaitApproval") {
|
|
1563
|
-
console.warn(
|
|
1564
|
-
`[byok/server] dropping task.approval_resolved for ${taskId}: not awaiting approval (state ${record.state})`
|
|
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`
|
|
1565
1776
|
);
|
|
1566
|
-
return;
|
|
1567
1777
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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, {});
|
|
1785
|
+
}
|
|
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`);
|
|
1792
|
+
}
|
|
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`
|
|
1571
1796
|
);
|
|
1572
|
-
return;
|
|
1573
1797
|
}
|
|
1574
|
-
|
|
1575
|
-
const
|
|
1576
|
-
if (
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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"
|
|
1586
1826
|
});
|
|
1587
1827
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
return updated;
|
|
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");
|
|
1834
|
+
}
|
|
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");
|
|
1842
|
+
}
|
|
1843
|
+
return existing;
|
|
1844
|
+
}
|
|
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");
|
|
1848
|
+
}
|
|
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;
|
|
1621
1860
|
}
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
/** Idempotent: cancelling an already-terminal task is a no-op, not an error. */
|
|
1890
|
+
async cancelTask(taskId, reason) {
|
|
1628
1891
|
const record = this.taskStore.get(taskId);
|
|
1629
|
-
if (!record)
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
1632
|
-
|
|
1892
|
+
if (!record) throw new Error(`unknown taskId: ${taskId}`);
|
|
1893
|
+
if (isTerminal(record.state)) return;
|
|
1894
|
+
this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason } });
|
|
1895
|
+
if (record.deviceId) {
|
|
1896
|
+
this.sendToDevice(record.deviceId, "task.cancel", { reason }, { taskId });
|
|
1633
1897
|
}
|
|
1634
|
-
this.forceFailOrDrop(taskId, `illegal transition ${record.state} -> ${target}`);
|
|
1635
1898
|
}
|
|
1636
1899
|
/**
|
|
1637
|
-
* M4 Phase 3
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
* `
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
* pre-
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
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.
|
|
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).
|
|
1911
|
+
*/
|
|
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.
|
|
1679
1926
|
*/
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1927
|
+
async approveTask(taskId, opts) {
|
|
1928
|
+
const record = this.taskStore.get(taskId);
|
|
1929
|
+
if (!record) throw new UnknownTaskError(taskId);
|
|
1930
|
+
if (record.state !== "AwaitApproval") {
|
|
1931
|
+
throw new TaskNotAwaitingApprovalError(taskId, record.state, "approve");
|
|
1932
|
+
}
|
|
1933
|
+
if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
|
|
1934
|
+
throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
|
|
1935
|
+
}
|
|
1936
|
+
this.applyOrFail(taskId, "Running", {});
|
|
1937
|
+
if (record.deviceId) {
|
|
1938
|
+
const approvalId = opts?.approvalId ?? record.pendingApprovalId;
|
|
1939
|
+
this.sendToDevice(record.deviceId, "task.approve", { approvalId }, { taskId });
|
|
1940
|
+
}
|
|
1685
1941
|
}
|
|
1686
1942
|
/**
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
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
|
-
|
|
1948
|
+
async rejectTask(taskId, reason, opts) {
|
|
1693
1949
|
const record = this.taskStore.get(taskId);
|
|
1694
|
-
if (!record)
|
|
1695
|
-
if (
|
|
1696
|
-
|
|
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");
|
|
1700
1953
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
-
});
|
|
1713
|
-
if (isTerminal(record.state)) {
|
|
1714
|
-
this.taskActivity.delete(record.taskId);
|
|
1954
|
+
if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
|
|
1955
|
+
throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
|
|
1715
1956
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
if (
|
|
1720
|
-
|
|
1721
|
-
|
|
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 });
|
|
1722
1963
|
}
|
|
1723
1964
|
}
|
|
1724
|
-
// ---------------------------------------------------------------------
|
|
1725
|
-
// task-lease reaper (Decision: Failed(retryable:true) on dark-device
|
|
1726
|
-
// timeout — no new task state, no new wire message)
|
|
1727
|
-
// ---------------------------------------------------------------------
|
|
1728
1965
|
/**
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
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.
|
|
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:
|
|
1796
1969
|
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
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.
|
|
1806
1982
|
*
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
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.
|
|
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).
|
|
1818
1990
|
*/
|
|
1819
|
-
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1991
|
+
async steerTask(taskId, text) {
|
|
1992
|
+
const record = this.taskStore.get(taskId);
|
|
1993
|
+
if (!record) throw new Error(`unknown taskId: ${taskId}`);
|
|
1994
|
+
if (isTerminal(record.state)) {
|
|
1995
|
+
throw new SteerRejectedError(taskId, "task_terminal", record.state, record.claimedRuntime);
|
|
1996
|
+
}
|
|
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`);
|
|
1829
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;
|
|
2016
|
+
}
|
|
2017
|
+
return void 0;
|
|
1830
2018
|
}
|
|
2019
|
+
// ---------------------------------------------------------------------
|
|
2020
|
+
// outbound envelope delivery + per-device seq/redelivery bookkeeping (§1.2, §9)
|
|
2021
|
+
// ---------------------------------------------------------------------
|
|
1831
2022
|
/**
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
1836
|
-
* `
|
|
1837
|
-
*
|
|
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).
|
|
2026
|
+
*
|
|
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.
|
|
1838
2033
|
*/
|
|
1839
|
-
|
|
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) {
|
|
1840
2049
|
const conn = this.connections.get(deviceId);
|
|
1841
|
-
if (
|
|
1842
|
-
|
|
2050
|
+
if (conn?.connected && conn.ws) {
|
|
2051
|
+
conn.ws.send(encodeEnvelope(envelope));
|
|
1843
2052
|
}
|
|
1844
|
-
|
|
1845
|
-
const lastSeenMs = Date.parse(conn.lastSeen);
|
|
1846
|
-
return nowMs - lastSeenMs >= this.taskLeaseMs ? lastSeenMs : void 0;
|
|
2053
|
+
this.settleLongPollWaiter(deviceId);
|
|
1847
2054
|
}
|
|
1848
|
-
/**
|
|
1849
|
-
|
|
2055
|
+
/**
|
|
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) {
|
|
1850
2072
|
const record = this.taskStore.get(taskId);
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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;
|
|
1855
2087
|
}
|
|
1856
2088
|
// ---------------------------------------------------------------------
|
|
1857
|
-
//
|
|
2089
|
+
// read-only accessors backing the public `machines` / `tasks` API
|
|
1858
2090
|
// ---------------------------------------------------------------------
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
|
|
1864
|
-
const deviceId = input.deviceId ?? this.pickFirstConnectedDevice(requiredToolsets);
|
|
1865
|
-
if (agentRef !== void 0 && input.deviceId === void 0) {
|
|
1866
|
-
throw new Error("Agent-bound dispatch requires an explicit deviceId for capability admission");
|
|
1867
|
-
}
|
|
1868
|
-
if (egressPolicy !== void 0 && agentRef === void 0) {
|
|
1869
|
-
throw new Error("Agent egress policy requires an explicit AgentRef; legacy task dispatch cannot consume it");
|
|
1870
|
-
}
|
|
1871
|
-
if (egressPolicy !== void 0 && input.sessionRef === void 0) {
|
|
1872
|
-
throw new Error("Agent egress policy requires an exact sessionRef");
|
|
1873
|
-
}
|
|
1874
|
-
if (!deviceId || !this.connections.get(deviceId)?.connected) {
|
|
1875
|
-
throw new Error(
|
|
1876
|
-
deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
|
|
1877
|
-
);
|
|
1878
|
-
}
|
|
1879
|
-
if (agentRef !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("agent-home-contract") ?? false)) {
|
|
1880
|
-
throw new Error(
|
|
1881
|
-
`device ${deviceId} did not advertise agent-home-contract capability; refusing Agent-bound dispatch`
|
|
1882
|
-
);
|
|
1883
|
-
}
|
|
1884
|
-
if (egressPolicy !== void 0 && !this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
|
|
1885
|
-
throw new Error(
|
|
1886
|
-
`device ${deviceId} did not advertise Agent egress policy and reliable acknowledgement capabilities; refusing before enqueue`
|
|
1887
|
-
);
|
|
1888
|
-
}
|
|
1889
|
-
if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
|
|
1890
|
-
throw new Error(
|
|
1891
|
-
`device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
|
|
1892
|
-
);
|
|
1893
|
-
}
|
|
1894
|
-
if (requiredToolsets !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("toolset-selection") ?? false)) {
|
|
1895
|
-
throw new Error(
|
|
1896
|
-
`device ${deviceId} did not advertise toolset-selection capability; refusing a task whose semantics require local MCP tools`
|
|
1897
|
-
);
|
|
1898
|
-
}
|
|
1899
|
-
if (requiredToolsets !== void 0) {
|
|
1900
|
-
const configuredToolsets = this.connections.get(deviceId)?.configuredToolsets;
|
|
1901
|
-
if (configuredToolsets === void 0) {
|
|
1902
|
-
throw new Error(
|
|
1903
|
-
`device ${deviceId} did not advertise its configured toolset inventory; refusing to guess from runtime capability`
|
|
1904
|
-
);
|
|
1905
|
-
}
|
|
1906
|
-
const configured = new Set(configuredToolsets);
|
|
1907
|
-
const missing = requiredToolsets.filter((toolsetId) => !configured.has(toolsetId));
|
|
1908
|
-
if (missing.length > 0) {
|
|
1909
|
-
throw new Error(
|
|
1910
|
-
`device ${deviceId} is missing required MCP toolset(s): ${missing.join(", ")}`
|
|
1911
|
-
);
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
if (dispatchSelection !== void 0 && input.runtime !== void 0 && input.runtime !== dispatchSelection.runtimeId) {
|
|
1915
|
-
throw new Error(
|
|
1916
|
-
`dispatch runtime ${input.runtime} does not match dispatchSelection.runtimeId ${dispatchSelection.runtimeId}`
|
|
1917
|
-
);
|
|
1918
|
-
}
|
|
1919
|
-
const taskId = generateTaskId();
|
|
1920
|
-
const policy = input.policy ?? DEFAULT_POLICY;
|
|
1921
|
-
const runtime = dispatchSelection?.runtimeId ?? input.runtime;
|
|
1922
|
-
const record = this.taskStore.create({
|
|
1923
|
-
taskId,
|
|
1924
|
-
instruction: input.instruction,
|
|
1925
|
-
runtime,
|
|
1926
|
-
policy,
|
|
1927
|
-
requiredToolsets,
|
|
1928
|
-
deviceId,
|
|
1929
|
-
sessionRef: input.sessionRef,
|
|
1930
|
-
agentRef
|
|
1931
|
-
});
|
|
1932
|
-
const queue = new AsyncEventQueue();
|
|
1933
|
-
let resolveResult;
|
|
1934
|
-
const result = new Promise((resolve) => {
|
|
1935
|
-
resolveResult = resolve;
|
|
1936
|
-
});
|
|
1937
|
-
this.runtimes.set(taskId, { queue, resolveResult, result });
|
|
1938
|
-
queue.push({ kind: "state", state: record.state, at: record.createdAt });
|
|
1939
|
-
this.serverEvents.push({ kind: "task.created", taskId, at: record.createdAt });
|
|
1940
|
-
const commonOffer = {
|
|
1941
|
-
instruction: input.instruction,
|
|
1942
|
-
policy,
|
|
1943
|
-
runtime,
|
|
1944
|
-
dispatchSelection,
|
|
1945
|
-
sessionRef: input.sessionRef
|
|
1946
|
-
};
|
|
1947
|
-
if (agentRef !== void 0 && egressPolicy !== void 0) {
|
|
1948
|
-
this.sendToDevice(
|
|
1949
|
-
deviceId,
|
|
1950
|
-
"task.offer_for_agent_with_egress",
|
|
1951
|
-
{
|
|
1952
|
-
...commonOffer,
|
|
1953
|
-
sessionRef: input.sessionRef,
|
|
1954
|
-
agentRef,
|
|
1955
|
-
egressPolicy,
|
|
1956
|
-
...requiredToolsets === void 0 ? {} : { requiredToolsets }
|
|
1957
|
-
},
|
|
1958
|
-
{ taskId, sessionRef: input.sessionRef }
|
|
1959
|
-
);
|
|
1960
|
-
} else if (agentRef !== void 0) {
|
|
1961
|
-
this.sendToDevice(
|
|
1962
|
-
deviceId,
|
|
1963
|
-
"task.offer_for_agent",
|
|
1964
|
-
{ ...commonOffer, agentRef, ...requiredToolsets === void 0 ? {} : { requiredToolsets } },
|
|
1965
|
-
{ taskId, sessionRef: input.sessionRef }
|
|
1966
|
-
);
|
|
1967
|
-
} else if (requiredToolsets === void 0) {
|
|
1968
|
-
this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef: input.sessionRef });
|
|
1969
|
-
} else {
|
|
1970
|
-
this.sendToDevice(
|
|
2091
|
+
listMachines() {
|
|
2092
|
+
return this.devices.list().map(({ deviceId, deviceName }) => {
|
|
2093
|
+
const conn = this.connections.get(deviceId);
|
|
2094
|
+
return {
|
|
1971
2095
|
deviceId,
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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
|
+
});
|
|
1978
2104
|
}
|
|
1979
|
-
/**
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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).
|
|
2117
|
+
*/
|
|
2118
|
+
getDeviceCapabilities(deviceId) {
|
|
2119
|
+
return this.connections.get(deviceId)?.capabilities;
|
|
2120
|
+
}
|
|
2121
|
+
hasDeviceCapabilities(deviceId, required) {
|
|
2122
|
+
const advertised = this.getDeviceCapabilities(deviceId);
|
|
2123
|
+
return advertised !== void 0 && required.every((capability) => advertised.includes(capability));
|
|
2124
|
+
}
|
|
2125
|
+
getAgentEgressReceipt(deviceId, eventId) {
|
|
2126
|
+
return this.agentEgressReceipts.get(this.agentEgressReceiptKey(deviceId, eventId));
|
|
2127
|
+
}
|
|
2128
|
+
getTask(taskId) {
|
|
2129
|
+
return this.taskStore.get(taskId);
|
|
2130
|
+
}
|
|
2131
|
+
listTasks() {
|
|
2132
|
+
return this.taskStore.list();
|
|
2133
|
+
}
|
|
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]++;
|
|
1991
2150
|
}
|
|
1992
|
-
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
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++;
|
|
1996
2154
|
}
|
|
1997
|
-
this.agentContentReadRequests.set(key, payload);
|
|
1998
|
-
this.sendToDevice(deviceId, "agent.content.read", payload, {});
|
|
1999
|
-
}
|
|
2000
|
-
buildTaskHandle(taskId) {
|
|
2001
|
-
const hub = this;
|
|
2002
2155
|
return {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
return hub.cancelTask(taskId, reason);
|
|
2011
|
-
},
|
|
2012
|
-
approve(opts) {
|
|
2013
|
-
return hub.approveTask(taskId, opts);
|
|
2014
|
-
},
|
|
2015
|
-
reject(reason, opts) {
|
|
2016
|
-
return hub.rejectTask(taskId, reason, opts);
|
|
2017
|
-
},
|
|
2018
|
-
steer(text) {
|
|
2019
|
-
return hub.steerTask(taskId, text);
|
|
2020
|
-
},
|
|
2021
|
-
result() {
|
|
2022
|
-
const runtime = hub.runtimes.get(taskId);
|
|
2023
|
-
if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
|
|
2024
|
-
return runtime.result;
|
|
2025
|
-
}
|
|
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
|
|
2026
2163
|
};
|
|
2027
2164
|
}
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
this.
|
|
2034
|
-
if (record.deviceId) {
|
|
2035
|
-
this.sendToDevice(record.deviceId, "task.cancel", { reason }, { taskId });
|
|
2036
|
-
}
|
|
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";
|
|
2037
2171
|
}
|
|
2172
|
+
};
|
|
2173
|
+
var PairingManager = class {
|
|
2174
|
+
codes = /* @__PURE__ */ new Map();
|
|
2038
2175
|
/**
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
2045
|
-
* `UnknownTaskError`/`TaskNotAwaitingApprovalError` doc comments for why
|
|
2046
|
-
* the two failure modes are now distinct typed errors rather than a
|
|
2047
|
-
* single generic `Error`. Every thrown message's TEXT is byte-for-byte
|
|
2048
|
-
* unchanged from M2/M3 — only the error's type changed (this is still also
|
|
2049
|
-
* 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.
|
|
2050
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
|
+
}
|
|
2051
2190
|
/**
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
2058
|
-
* {@link StaleApprovalError} — no transition, no `task.approve` sent. If
|
|
2059
|
-
* this hub never recorded a `pendingApprovalId` (a legacy daemon that
|
|
2060
|
-
* never reported one), the call proceeds untargeted exactly as before.
|
|
2061
|
-
* The outgoing `task.approve` carries `approvalId`: the caller-supplied
|
|
2062
|
-
* one if given, else this hub's own recorded one, else omitted entirely
|
|
2063
|
-
* (legacy wire shape) — so the daemon can apply its own exact-match check
|
|
2064
|
-
* 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.
|
|
2065
2197
|
*/
|
|
2066
|
-
|
|
2067
|
-
const record = this.
|
|
2068
|
-
if (!record)
|
|
2069
|
-
|
|
2070
|
-
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");
|
|
2071
2202
|
}
|
|
2072
|
-
if (
|
|
2073
|
-
throw new
|
|
2203
|
+
if (record.used) {
|
|
2204
|
+
throw new PairingCodeInvalidError("code already used");
|
|
2074
2205
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const approvalId = opts?.approvalId ?? record.pendingApprovalId;
|
|
2078
|
-
this.sendToDevice(record.deviceId, "task.approve", { approvalId }, { taskId });
|
|
2206
|
+
if (Date.now() > record.expiresAt) {
|
|
2207
|
+
throw new PairingCodeInvalidError("code expired");
|
|
2079
2208
|
}
|
|
2209
|
+
record.used = true;
|
|
2210
|
+
return record.claims;
|
|
2080
2211
|
}
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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);
|
|
2092
2246
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
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;
|
|
2095
2256
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2257
|
+
const deviceId = generateDeviceId();
|
|
2258
|
+
deps.devices.register({
|
|
2259
|
+
tenantId: claims.tenantId,
|
|
2260
|
+
productId: claims.productId,
|
|
2261
|
+
deviceId,
|
|
2262
|
+
deviceName,
|
|
2263
|
+
devicePublicKey
|
|
2098
2264
|
});
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2265
|
+
const device = deps.devices.get(claims.tenantId, deviceId);
|
|
2266
|
+
if (device === void 0) {
|
|
2267
|
+
throw new Error("paired device row was not persisted");
|
|
2102
2268
|
}
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
const
|
|
2132
|
-
|
|
2133
|
-
if (
|
|
2134
|
-
|
|
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;
|
|
2135
2339
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
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);
|
|
2138
2347
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2348
|
+
const blobId = c.req.param("id");
|
|
2349
|
+
if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
|
|
2350
|
+
return c.json({ error: "storage_integrity_mismatch" }, 422);
|
|
2141
2351
|
}
|
|
2142
|
-
if (!
|
|
2143
|
-
|
|
2352
|
+
if (!await deps.blobStore.exists(blobId)) {
|
|
2353
|
+
return c.json({ error: "storage_reservation_not_found" }, 404);
|
|
2144
2354
|
}
|
|
2145
|
-
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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);
|
|
2155
2370
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
* `opts`'s type mirrors `createEnvelope`'s own per-type conditional
|
|
2167
|
-
* requiredness (finding F1) minus `seq` (computed fresh right here on
|
|
2168
|
-
* every call, never caller-supplied) — so every one of this method's 6
|
|
2169
|
-
* callers below must supply `taskId` for the 5 types that need it
|
|
2170
|
-
* (everything except `conn.ack`), same as calling `createEnvelope`
|
|
2171
|
-
* directly would require.
|
|
2172
|
-
*/
|
|
2173
|
-
sendToDevice(deviceId, type, payload, opts) {
|
|
2174
|
-
this.envelopesOutCount++;
|
|
2175
|
-
const outbox = this.getOrCreateOutbox(deviceId);
|
|
2176
|
-
const seq = outbox.nextSeq++;
|
|
2177
|
-
const combinedOpts = { ...opts, seq };
|
|
2178
|
-
const envelope = createEnvelope(type, payload, combinedOpts);
|
|
2179
|
-
const taskId = opts.taskId;
|
|
2180
|
-
const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
|
|
2181
|
-
const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read";
|
|
2182
|
-
outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
|
|
2183
|
-
if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
|
|
2184
|
-
this.deliverToDevice(deviceId, envelope);
|
|
2185
|
-
return envelope;
|
|
2186
|
-
}
|
|
2187
|
-
deliverToDevice(deviceId, envelope) {
|
|
2188
|
-
const conn = this.connections.get(deviceId);
|
|
2189
|
-
if (conn?.connected && conn.ws) {
|
|
2190
|
-
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);
|
|
2191
2381
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
if (!outbox) return [];
|
|
2206
|
-
return outbox.ring.filter(
|
|
2207
|
-
(entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
|
|
2208
|
-
).map((entry) => entry.envelope);
|
|
2209
|
-
}
|
|
2210
|
-
isTaskTerminal(taskId) {
|
|
2211
|
-
const record = this.taskStore.get(taskId);
|
|
2212
|
-
return !record || isTerminal(record.state);
|
|
2213
|
-
}
|
|
2214
|
-
/** The highest `seq` assigned to `deviceId` so far — the redelivery cursor to hand back on a poll/reconnect. */
|
|
2215
|
-
currentCursor(deviceId) {
|
|
2216
|
-
const outbox = this.outboxes.get(deviceId);
|
|
2217
|
-
return outbox ? outbox.nextSeq - 1 : 0;
|
|
2218
|
-
}
|
|
2219
|
-
getOrCreateOutbox(deviceId) {
|
|
2220
|
-
let outbox = this.outboxes.get(deviceId);
|
|
2221
|
-
if (!outbox) {
|
|
2222
|
-
outbox = { nextSeq: 1, ring: [] };
|
|
2223
|
-
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;
|
|
2224
2395
|
}
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
}
|
|
2244
|
-
/**
|
|
2245
|
-
* M5 (approval targeting, hello-capability plumbing): the capability flags
|
|
2246
|
-
* `deviceId`'s CURRENT connection advertised in its `conn.hello` —
|
|
2247
|
-
* `undefined` if this hub has no connection state for the device at all,
|
|
2248
|
-
* or one that never had capabilities recorded (a pre-M5 daemon, or a
|
|
2249
|
-
* device this hub only ever saw over long-poll with no prior WS hello —
|
|
2250
|
-
* see `ConnectionState.capabilities`'s own doc comment). Read fresh from
|
|
2251
|
-
* live connection state, mirroring `listMachines()`'s own convention; an
|
|
2252
|
-
* embedder can use this to distinguish a targeting-capable device from a
|
|
2253
|
-
* legacy one for its own observability/UI purposes (see `version.ts`'s
|
|
2254
|
-
* `approval-targeting` flag doc comment for why this is informational
|
|
2255
|
-
* only, never a correctness gate).
|
|
2256
|
-
*/
|
|
2257
|
-
getDeviceCapabilities(deviceId) {
|
|
2258
|
-
return this.connections.get(deviceId)?.capabilities;
|
|
2259
|
-
}
|
|
2260
|
-
hasDeviceCapabilities(deviceId, required) {
|
|
2261
|
-
const advertised = this.getDeviceCapabilities(deviceId);
|
|
2262
|
-
return advertised !== void 0 && required.every((capability) => advertised.includes(capability));
|
|
2263
|
-
}
|
|
2264
|
-
getAgentEgressReceipt(deviceId, eventId) {
|
|
2265
|
-
return this.agentEgressReceipts.get(this.agentEgressReceiptKey(deviceId, eventId));
|
|
2266
|
-
}
|
|
2267
|
-
getTask(taskId) {
|
|
2268
|
-
return this.taskStore.get(taskId);
|
|
2269
|
-
}
|
|
2270
|
-
listTasks() {
|
|
2271
|
-
return this.taskStore.list();
|
|
2272
|
-
}
|
|
2273
|
-
// ---------------------------------------------------------------------
|
|
2274
|
-
// observability (M4 Phase 4, part B.1) — in-process only; see
|
|
2275
|
-
// `types.ts`'s `HubStats`/`CreateByokServerOptions.healthzRoute` doc
|
|
2276
|
-
// comments for why this is never exposed over HTTP by this SDK itself.
|
|
2277
|
-
// ---------------------------------------------------------------------
|
|
2278
|
-
/**
|
|
2279
|
-
* A plain, serializable snapshot of this hub's current state, derived from
|
|
2280
|
-
* existing structures (`connections`, `taskStore`) plus the small counters
|
|
2281
|
-
* this file already maintains for exactly this purpose — no new
|
|
2282
|
-
* bookkeeping structures beyond those counters. See {@link HubStats}
|
|
2283
|
-
* (`types.ts`) for the full field-by-field contract.
|
|
2284
|
-
*/
|
|
2285
|
-
stats() {
|
|
2286
|
-
const taskCountsByState = Object.fromEntries(TASK_STATES.map((state) => [state, 0]));
|
|
2287
|
-
for (const record of this.taskStore.list()) {
|
|
2288
|
-
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++;
|
|
2289
2414
|
}
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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);
|
|
2293
2424
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
};
|
|
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
|
+
}
|
|
2305
2447
|
var IllegalTaskTransitionError = class extends Error {
|
|
2306
2448
|
constructor(taskId, from, to) {
|
|
2307
2449
|
super(`illegal task transition for ${taskId}: ${from} -> ${to}`);
|
|
@@ -3035,7 +3177,10 @@ function createByokServer(opts) {
|
|
|
3035
3177
|
createPairingCode: (claims) => pairing.createPairingCode(claims)
|
|
3036
3178
|
},
|
|
3037
3179
|
dispatch: (input) => hub.dispatch(input),
|
|
3180
|
+
dispatchFreshAgentEgress: (input) => hub.dispatchFreshAgentEgress(input),
|
|
3038
3181
|
requestAgentContentRead: (input) => hub.requestAgentContentRead(input),
|
|
3182
|
+
enqueueAgentHomeProjection: (input) => hub.enqueueAgentHomeProjection(input),
|
|
3183
|
+
readAgentHomeProjection: (deviceId, requestId) => hub.readAgentHomeProjection(deviceId, requestId),
|
|
3039
3184
|
tasks: {
|
|
3040
3185
|
get: (taskId) => hub.getTask(taskId),
|
|
3041
3186
|
list: () => hub.listTasks()
|
|
@@ -3059,6 +3204,6 @@ function createByokServer(opts) {
|
|
|
3059
3204
|
};
|
|
3060
3205
|
}
|
|
3061
3206
|
|
|
3062
|
-
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 };
|
|
3063
3208
|
//# sourceMappingURL=index.js.map
|
|
3064
3209
|
//# sourceMappingURL=index.js.map
|