@byok-sdk/server 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { mkdir, writeFile, readFile } from 'fs/promises';
5
5
  import { mkdtempSync, mkdirSync, existsSync, chmodSync } from 'fs';
6
6
  import { tmpdir } from 'os';
7
7
  import path, { dirname } from 'path';
8
- import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, DispatchSelectionSchema, RequiredToolsetsSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, BYOK_CHALLENGE_PATH, ChallengeRequestSchema, BYOK_TOKEN_PATH, TokenRequestSchema, BYOK_BLOBS_PATH, CreateBlobRequestSchema, BYOK_BLOB_FINALIZE_ROUTE, BYOK_BLOB_URL_ROUTE, BYOK_BLOB_CONTENT_ROUTE, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendRequestSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
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';
9
9
  import { Hono } from 'hono';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { createRequire } from 'module';
@@ -266,8 +266,6 @@ function generateDeviceId() {
266
266
  function generateTaskId() {
267
267
  return `task_${randomUUID()}`;
268
268
  }
269
-
270
- // src/pairing.ts
271
269
  var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
272
270
  var PairingCodeInvalidError = class extends Error {
273
271
  constructor(reason) {
@@ -320,13 +318,14 @@ function validatePairingCodeClaims(claims) {
320
318
  throw new TypeError("createPairingCode requires { tenantId, productId } claims");
321
319
  }
322
320
  const { tenantId, productId } = claims;
323
- if (typeof tenantId !== "string" || tenantId.length === 0) {
324
- throw new TypeError("createPairingCode requires a non-empty tenantId");
321
+ const tenantResult = PairResponseTenantIdSchema.safeParse(tenantId);
322
+ if (!tenantResult.success) {
323
+ throw new TypeError("createPairingCode requires a valid bounded tenantId");
325
324
  }
326
325
  if (typeof productId !== "string" || productId.length === 0) {
327
326
  throw new TypeError("createPairingCode requires a non-empty productId");
328
327
  }
329
- return { tenantId, productId };
328
+ return { tenantId: tenantResult.data, productId };
330
329
  }
331
330
 
332
331
  // src/http.ts
@@ -366,12 +365,21 @@ function buildHonoApp(deps) {
366
365
  deviceName,
367
366
  devicePublicKey
368
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
+ }
369
372
  const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
370
- deviceId,
371
- tenantId: claims.tenantId,
372
- productId: claims.productId
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
373
382
  });
374
- const response = { deviceId, accessToken, refreshHint: expiresAt };
375
383
  return c.json(response, 200);
376
384
  });
377
385
  app.post(BYOK_CHALLENGE_PATH, async (c) => {
@@ -500,7 +508,7 @@ function buildHonoApp(deps) {
500
508
  let accepted = 0;
501
509
  let rejected = 0;
502
510
  for (const envelope of parsed.data.messages) {
503
- const result = deps.hub.handleInbound(principal.deviceId, envelope);
511
+ const result = deps.hub.handleInbound(principal.deviceId, envelope, principal.productId);
504
512
  if (result === "rate_limited") {
505
513
  return c.json({ error: "rate limit exceeded" }, 429);
506
514
  }
@@ -711,6 +719,25 @@ function isTerminal(state) {
711
719
  function isClaimedState(state) {
712
720
  return state === "Claimed" || state === "Running" || state === "AwaitApproval";
713
721
  }
722
+ function sameAgentRef(expected, actual) {
723
+ return actual?.agentId === expected.agentId && actual.profileRevision === expected.profileRevision;
724
+ }
725
+ function sameAgentEgressPayload(expected, actual) {
726
+ return sameAgentRef(expected.agentRef, actual.agentRef) && expected.sessionRef === actual.sessionRef && expected.policyRevision === actual.policyRevision && expected.eventId === actual.eventId && expected.cursor === actual.cursor && expected.contentHash === actual.contentHash && expected.byteCount === actual.byteCount && JSON.stringify(expected.payload) === JSON.stringify(actual.payload);
727
+ }
728
+ function matchesContentReadReceipt(request, receipt) {
729
+ return request.requestId === receipt.requestId && request.surface === receipt.surface && request.actor.kind === receipt.actor.kind && request.actor.id === receipt.actor.id && sameAgentRef(request.agentRef, receipt.agentRef) && request.sessionRef === receipt.sessionRef && request.runtime === receipt.runtime && request.cwd === receipt.cwd && request.policyRevision === receipt.policyRevision && request.target === receipt.target && request.mimeType === receipt.mimeType && request.decodeAs === receipt.decodeAs;
730
+ }
731
+ function contentReadCapability(surface) {
732
+ switch (surface) {
733
+ case "workspace":
734
+ return AGENT_CONTENT_WORKSPACE_READ_CAPABILITY;
735
+ case "transcript":
736
+ return AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY;
737
+ case "artifact":
738
+ return AGENT_CONTENT_ARTIFACT_READ_CAPABILITY;
739
+ }
740
+ }
714
741
  var UnknownTaskError = class extends Error {
715
742
  constructor(taskId) {
716
743
  super(`unknown taskId: ${taskId}`);
@@ -788,6 +815,12 @@ var ConnectionHub = class {
788
815
  longPollWaiters = /* @__PURE__ */ new Map();
789
816
  runtimes = /* @__PURE__ */ new Map();
790
817
  serverEvents = new AsyncEventQueue();
818
+ /** First-write-wins reliable facts; the reference composition's bounded in-memory readback. */
819
+ agentEgressReceipts = /* @__PURE__ */ new Map();
820
+ /** Accepted requests are the authority that later receipts/transfers must echo exactly. */
821
+ agentContentReadRequests = /* @__PURE__ */ new Map();
822
+ /** Content-free explicit-read audit facts keyed by exact authenticated device/request identity. */
823
+ agentContentReceipts = /* @__PURE__ */ new Map();
791
824
  /**
792
825
  * Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
793
826
  * reaper's condition (c), see the "task-lease reaper" section below. Reset
@@ -844,12 +877,13 @@ var ConnectionHub = class {
844
877
  * connection this hub never learns capabilities for simply reads back
845
878
  * `undefined` from {@link getDeviceCapabilities}.
846
879
  */
847
- registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets) {
880
+ registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets, clientVersion) {
848
881
  const at = (/* @__PURE__ */ new Date()).toISOString();
849
882
  this.connections.set(deviceId, {
850
883
  ws,
851
884
  connected: true,
852
885
  lastSeen: at,
886
+ clientVersion,
853
887
  runtimes,
854
888
  capabilities,
855
889
  configuredToolsets
@@ -993,9 +1027,10 @@ var ConnectionHub = class {
993
1027
  * taskStore lookup or dedup bookkeeping. See {@link handleRateLimited}
994
1028
  * for what happens on exceed (never a silent drop).
995
1029
  * 1. **type-allow (P2)** — only {@link DAEMON_TO_SERVER_TYPES} may pass; a
996
- * server -> daemon type (or anything unrecognized, e.g. a stale/future
997
- * `conn.hello` outside the handshake) arriving inbound is rejected
998
- * before it's dispatched or counted accepted.
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.
999
1034
  * 2. **ownership (N2)** — an envelope for a task already owned by a
1000
1035
  * *different* device is dropped (logged), never force-failed:
1001
1036
  * force-failing on an authz mismatch would let an attacker who merely
@@ -1015,16 +1050,34 @@ var ConnectionHub = class {
1015
1050
  * wire-level success even though no handler ran a second time; only
1016
1051
  * `rejected`/`rate_limited` (gate steps 0-2) are excluded from that count.
1017
1052
  */
1018
- handleInbound(deviceId, envelope) {
1053
+ handleInbound(deviceId, envelope, authenticatedProductId) {
1019
1054
  this.envelopesInCount++;
1020
1055
  if (!this.rateLimiter.consume(deviceId)) {
1021
1056
  this.handleRateLimited(deviceId);
1022
1057
  return "rate_limited";
1023
1058
  }
1024
1059
  this.rateLimitEventEmittedFor.delete(deviceId);
1060
+ if (envelope.type === "conn.hello") {
1061
+ const payload = envelope.payload;
1062
+ if (authenticatedProductId === void 0 || payload.deviceId !== deviceId || payload.productId !== authenticatedProductId || !payload.protocolVersions.includes(PROTOCOL_VERSION)) {
1063
+ return "rejected";
1064
+ }
1065
+ if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
1066
+ this.dedupDropCount++;
1067
+ return "duplicate";
1068
+ }
1069
+ this.registerLongPollHello(deviceId, payload);
1070
+ return "accepted";
1071
+ }
1025
1072
  if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
1026
1073
  return "rejected";
1027
1074
  }
1075
+ if (envelope.type === "agent.egress.reliable") {
1076
+ return this.handleAgentEgressReliable(deviceId, envelope.payload);
1077
+ }
1078
+ if (envelope.type === "agent.content.receipt") {
1079
+ return this.handleAgentContentReceipt(deviceId, envelope.payload);
1080
+ }
1028
1081
  const taskId = envelope.task_id;
1029
1082
  if (taskId === void 0) return "rejected";
1030
1083
  const record = this.taskStore.get(taskId);
@@ -1039,6 +1092,100 @@ var ConnectionHub = class {
1039
1092
  this.dispatchToHandler(deviceId, taskId, envelope);
1040
1093
  return "accepted";
1041
1094
  }
1095
+ /**
1096
+ * Store before acking. Replays must agree on every identity/cursor/hash
1097
+ * field and receive the original receipt id; a same event id with changed
1098
+ * facts is rejected rather than treated as an update.
1099
+ */
1100
+ handleAgentEgressReliable(deviceId, payload) {
1101
+ if (!this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1102
+ return "rejected";
1103
+ }
1104
+ const key = this.agentEgressReceiptKey(deviceId, payload.eventId);
1105
+ const existing = this.agentEgressReceipts.get(key);
1106
+ if (existing !== void 0) {
1107
+ if (!sameAgentEgressPayload(existing.payload, payload)) return "rejected";
1108
+ this.sendAgentEgressAck(deviceId, existing);
1109
+ this.dedupDropCount++;
1110
+ return "duplicate";
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();
1188
+ }
1042
1189
  /**
1043
1190
  * M4 Phase 4 (part A): `deviceId` just exceeded its inbound-envelope rate
1044
1191
  * limit. Never a silent drop: counts the occurrence
@@ -1140,7 +1287,7 @@ var ConnectionHub = class {
1140
1287
  this.onStarted(envelope.task_id, envelope.payload);
1141
1288
  return;
1142
1289
  case "task.decline":
1143
- this.onDecline(envelope.task_id, envelope.payload);
1290
+ this.onDecline(deviceId, envelope.task_id, envelope.payload);
1144
1291
  return;
1145
1292
  case "task.progress":
1146
1293
  this.onProgress(envelope.task_id, envelope.payload);
@@ -1206,6 +1353,14 @@ var ConnectionHub = class {
1206
1353
  onClaim(deviceId, taskId, payload) {
1207
1354
  const record = this.taskStore.get(taskId);
1208
1355
  if (!record) return;
1356
+ if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1357
+ console.warn(`[byok/server] dropping task.claim for ${taskId}: offered to a different device`);
1358
+ return;
1359
+ }
1360
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1361
+ this.forceFailOrDrop(taskId, "task.claim AgentRef does not exactly match the offered AgentRef");
1362
+ return;
1363
+ }
1209
1364
  if (record.state === "Claimed" || record.state === "Running") return;
1210
1365
  this.applyOrFail(taskId, "Claimed", {
1211
1366
  deviceId,
@@ -1230,10 +1385,18 @@ var ConnectionHub = class {
1230
1385
  * ever legal from `Offered`; anything else is stale. Ownership is already
1231
1386
  * enforced by {@link handleInbound} (N2) before this runs.
1232
1387
  */
1233
- onDecline(taskId, payload) {
1388
+ onDecline(deviceId, taskId, payload) {
1234
1389
  const record = this.taskStore.get(taskId);
1235
1390
  if (!record) return;
1236
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
+ }
1237
1400
  this.applyOrFail(taskId, "Failed", {
1238
1401
  result: { state: "Failed", reason: payload.reason, retryable: payload.retryable }
1239
1402
  });
@@ -1283,6 +1446,10 @@ var ConnectionHub = class {
1283
1446
  const record = this.taskStore.get(taskId);
1284
1447
  if (!record) return;
1285
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;
1452
+ }
1286
1453
  this.resumeIfImplicitlyApproved(record);
1287
1454
  const result = {
1288
1455
  state: "Complete",
@@ -1306,6 +1473,10 @@ var ConnectionHub = class {
1306
1473
  const record = this.taskStore.get(taskId);
1307
1474
  if (!record) return;
1308
1475
  if (isTerminal(record.state)) return;
1476
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1477
+ this.forceFailOrDrop(taskId, "task.fail AgentRef does not exactly match the offered AgentRef");
1478
+ return;
1479
+ }
1309
1480
  const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
1310
1481
  this.applyOrFail(taskId, "Failed", { result });
1311
1482
  }
@@ -1324,6 +1495,10 @@ var ConnectionHub = class {
1324
1495
  if (!record) return;
1325
1496
  if (record.state === "Cancelled") return;
1326
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;
1501
+ }
1327
1502
  this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
1328
1503
  }
1329
1504
  /**
@@ -1682,14 +1857,35 @@ var ConnectionHub = class {
1682
1857
  // dispatch() and the TaskHandle it returns
1683
1858
  // ---------------------------------------------------------------------
1684
1859
  async dispatch(input) {
1860
+ const agentRef = input.agentRef === void 0 ? void 0 : AgentRefSchema.parse(input.agentRef);
1861
+ const egressPolicy = input.egressPolicy === void 0 ? void 0 : AgentEgressPolicySchema.parse(input.egressPolicy);
1685
1862
  const dispatchSelection = input.dispatchSelection === void 0 ? void 0 : DispatchSelectionSchema.parse(input.dispatchSelection);
1686
1863
  const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
1687
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
+ }
1688
1874
  if (!deviceId || !this.connections.get(deviceId)?.connected) {
1689
1875
  throw new Error(
1690
1876
  deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
1691
1877
  );
1692
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
+ }
1693
1889
  if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
1694
1890
  throw new Error(
1695
1891
  `device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
@@ -1730,7 +1926,8 @@ var ConnectionHub = class {
1730
1926
  policy,
1731
1927
  requiredToolsets,
1732
1928
  deviceId,
1733
- sessionRef: input.sessionRef
1929
+ sessionRef: input.sessionRef,
1930
+ agentRef
1734
1931
  });
1735
1932
  const queue = new AsyncEventQueue();
1736
1933
  let resolveResult;
@@ -1747,7 +1944,27 @@ var ConnectionHub = class {
1747
1944
  dispatchSelection,
1748
1945
  sessionRef: input.sessionRef
1749
1946
  };
1750
- if (requiredToolsets === void 0) {
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) {
1751
1968
  this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef: input.sessionRef });
1752
1969
  } else {
1753
1970
  this.sendToDevice(
@@ -1759,6 +1976,27 @@ var ConnectionHub = class {
1759
1976
  }
1760
1977
  return this.buildTaskHandle(taskId);
1761
1978
  }
1979
+ /** Capability-gated control-plane read request; no request enters the outbox on omission. */
1980
+ async requestAgentContentRead(input) {
1981
+ const payload = AgentContentReadPayloadSchema.parse(input.payload);
1982
+ const deviceId = input.deviceId;
1983
+ if (!this.connections.get(deviceId)?.connected) {
1984
+ throw new Error(`device ${deviceId} is not connected`);
1985
+ }
1986
+ const capability = contentReadCapability(payload.surface);
1987
+ if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1988
+ throw new Error(
1989
+ `device ${deviceId} did not advertise ${capability} and reliable acknowledgement support; refusing Agent content read before enqueue`
1990
+ );
1991
+ }
1992
+ const key = `${deviceId}\0${payload.requestId}`;
1993
+ const existing = this.agentContentReadRequests.get(key);
1994
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(payload)) {
1995
+ throw new Error(`Agent content request ${payload.requestId} already exists with a different immutable body`);
1996
+ }
1997
+ this.agentContentReadRequests.set(key, payload);
1998
+ this.sendToDevice(deviceId, "agent.content.read", payload, {});
1999
+ }
1762
2000
  buildTaskHandle(taskId) {
1763
2001
  const hub = this;
1764
2002
  return {
@@ -1887,8 +2125,7 @@ var ConnectionHub = class {
1887
2125
  * not {@link getDeviceCapabilities}, not `ConnectionState.runtimes`, and
1888
2126
  * with no fallback to either when the snapshot is absent. See
1889
2127
  * {@link SteerRejectedError} for why a connection-sourced input is wrong
1890
- * both in scope (describes a daemon build, not this task's runtime) and in
1891
- * reach (absent entirely on long-poll-only daemons).
2128
+ * in scope (it describes a daemon build, not this task's runtime).
1892
2129
  */
1893
2130
  async steerTask(taskId, text) {
1894
2131
  const record = this.taskStore.get(taskId);
@@ -1941,7 +2178,8 @@ var ConnectionHub = class {
1941
2178
  const envelope = createEnvelope(type, payload, combinedOpts);
1942
2179
  const taskId = opts.taskId;
1943
2180
  const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
1944
- outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal });
2181
+ const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read";
2182
+ outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
1945
2183
  if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
1946
2184
  this.deliverToDevice(deviceId, envelope);
1947
2185
  return envelope;
@@ -1966,7 +2204,7 @@ var ConnectionHub = class {
1966
2204
  const outbox = this.outboxes.get(deviceId);
1967
2205
  if (!outbox) return [];
1968
2206
  return outbox.ring.filter(
1969
- (entry) => entry.seq > cursor && entry.taskId !== void 0 && (!this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
2207
+ (entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
1970
2208
  ).map((entry) => entry.envelope);
1971
2209
  }
1972
2210
  isTaskTerminal(taskId) {
@@ -1997,6 +2235,7 @@ var ConnectionHub = class {
1997
2235
  deviceName,
1998
2236
  connected: conn?.connected ?? false,
1999
2237
  lastSeen: conn?.lastSeen,
2238
+ ...conn?.clientVersion === void 0 ? {} : { clientVersion: conn.clientVersion },
2000
2239
  runtimes: conn?.runtimes,
2001
2240
  configuredToolsets: conn?.configuredToolsets ? [...conn.configuredToolsets] : void 0
2002
2241
  };
@@ -2018,6 +2257,13 @@ var ConnectionHub = class {
2018
2257
  getDeviceCapabilities(deviceId) {
2019
2258
  return this.connections.get(deviceId)?.capabilities;
2020
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
+ }
2021
2267
  getTask(taskId) {
2022
2268
  return this.taskStore.get(taskId);
2023
2269
  }
@@ -2081,6 +2327,7 @@ var InMemoryTaskStore = class {
2081
2327
  requiredToolsets: input.requiredToolsets,
2082
2328
  deviceId: input.deviceId,
2083
2329
  sessionRef: input.sessionRef,
2330
+ agentRef: input.agentRef,
2084
2331
  createdAt: now,
2085
2332
  updatedAt: now
2086
2333
  };
@@ -2251,7 +2498,8 @@ function handleConnection(ws, principal, deps) {
2251
2498
  ws,
2252
2499
  payload.runtimes,
2253
2500
  payload.capabilities,
2254
- payload.configuredToolsets
2501
+ payload.configuredToolsets,
2502
+ payload.clientVersion
2255
2503
  );
2256
2504
  deps.hub.sendConnAck(deviceId, SUPPORTED_CAPABILITIES);
2257
2505
  if (payload.cursor !== void 0) {
@@ -2365,6 +2613,7 @@ CREATE TABLE IF NOT EXISTS tasks (
2365
2613
  policy_json TEXT NOT NULL,
2366
2614
  device_id TEXT,
2367
2615
  session_ref TEXT,
2616
+ agent_ref_json TEXT,
2368
2617
  created_at TEXT NOT NULL,
2369
2618
  updated_at TEXT NOT NULL,
2370
2619
  result_json TEXT,
@@ -2385,7 +2634,8 @@ var ADDITIVE_COLUMNS = [
2385
2634
  {
2386
2635
  name: "claimed_runtime_capabilities_json",
2387
2636
  ddl: "ALTER TABLE tasks ADD COLUMN claimed_runtime_capabilities_json TEXT"
2388
- }
2637
+ },
2638
+ { name: "agent_ref_json", ddl: "ALTER TABLE tasks ADD COLUMN agent_ref_json TEXT" }
2389
2639
  ];
2390
2640
  function currentTaskColumns(db) {
2391
2641
  return new Set(db.prepare("PRAGMA table_info(tasks)").all().map((c) => c.name));
@@ -2411,6 +2661,7 @@ function rowToRecord(row) {
2411
2661
  const resultJson = row.result_json;
2412
2662
  const requiredToolsetsJson = row.required_toolsets_json;
2413
2663
  const claimedRuntimeCapabilitiesJson = row.claimed_runtime_capabilities_json;
2664
+ const agentRefJson = row.agent_ref_json;
2414
2665
  return {
2415
2666
  taskId: row.task_id,
2416
2667
  state: row.state,
@@ -2420,6 +2671,7 @@ function rowToRecord(row) {
2420
2671
  policy: JSON.parse(row.policy_json),
2421
2672
  deviceId: row.device_id ?? void 0,
2422
2673
  sessionRef: row.session_ref ?? void 0,
2674
+ agentRef: agentRefJson ? JSON.parse(agentRefJson) : void 0,
2423
2675
  createdAt: row.created_at,
2424
2676
  updatedAt: row.updated_at,
2425
2677
  result: resultJson ? JSON.parse(resultJson) : void 0,
@@ -2444,13 +2696,13 @@ var SqliteTaskStore = class {
2444
2696
  secureSqliteFilePermissions(opts.path);
2445
2697
  this.insertStmt = this.db.prepare(
2446
2698
  `INSERT INTO tasks
2447
- (task_id, state, instruction, runtime, required_toolsets_json, policy_json, device_id, session_ref, created_at, updated_at, result_json)
2448
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2699
+ (task_id, state, instruction, runtime, required_toolsets_json, policy_json, device_id, session_ref, agent_ref_json, created_at, updated_at, result_json)
2700
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2449
2701
  );
2450
2702
  this.updateStmt = this.db.prepare(
2451
2703
  `UPDATE tasks SET
2452
2704
  state = ?, instruction = ?, runtime = ?, required_toolsets_json = ?, policy_json = ?, device_id = ?,
2453
- session_ref = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2705
+ session_ref = ?, agent_ref_json = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2454
2706
  claimed_runtime = ?, claimed_runtime_capabilities_json = ?
2455
2707
  WHERE task_id = ? AND state = ?`
2456
2708
  );
@@ -2478,6 +2730,7 @@ var SqliteTaskStore = class {
2478
2730
  policy: input.policy,
2479
2731
  deviceId: input.deviceId,
2480
2732
  sessionRef: input.sessionRef,
2733
+ agentRef: input.agentRef,
2481
2734
  createdAt: now,
2482
2735
  updatedAt: now
2483
2736
  };
@@ -2490,6 +2743,7 @@ var SqliteTaskStore = class {
2490
2743
  JSON.stringify(record.policy),
2491
2744
  record.deviceId ?? null,
2492
2745
  record.sessionRef ?? null,
2746
+ record.agentRef ? JSON.stringify(record.agentRef) : null,
2493
2747
  record.createdAt,
2494
2748
  record.updatedAt,
2495
2749
  record.result ? JSON.stringify(record.result) : null
@@ -2548,6 +2802,7 @@ var SqliteTaskStore = class {
2548
2802
  JSON.stringify(updated.policy),
2549
2803
  updated.deviceId ?? null,
2550
2804
  updated.sessionRef ?? null,
2805
+ updated.agentRef ? JSON.stringify(updated.agentRef) : null,
2551
2806
  updated.createdAt,
2552
2807
  updated.updatedAt,
2553
2808
  updated.result ? JSON.stringify(updated.result) : null,
@@ -2780,10 +3035,14 @@ function createByokServer(opts) {
2780
3035
  createPairingCode: (claims) => pairing.createPairingCode(claims)
2781
3036
  },
2782
3037
  dispatch: (input) => hub.dispatch(input),
3038
+ requestAgentContentRead: (input) => hub.requestAgentContentRead(input),
2783
3039
  tasks: {
2784
3040
  get: (taskId) => hub.getTask(taskId),
2785
3041
  list: () => hub.listTasks()
2786
3042
  },
3043
+ egress: {
3044
+ get: (deviceId, eventId) => hub.getAgentEgressReceipt(deviceId, eventId)
3045
+ },
2787
3046
  machines: {
2788
3047
  list: () => hub.listMachines()
2789
3048
  },