@byok-sdk/server 0.5.0 → 0.6.1

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, 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, 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';
@@ -500,7 +500,7 @@ function buildHonoApp(deps) {
500
500
  let accepted = 0;
501
501
  let rejected = 0;
502
502
  for (const envelope of parsed.data.messages) {
503
- const result = deps.hub.handleInbound(principal.deviceId, envelope);
503
+ const result = deps.hub.handleInbound(principal.deviceId, envelope, principal.productId);
504
504
  if (result === "rate_limited") {
505
505
  return c.json({ error: "rate limit exceeded" }, 429);
506
506
  }
@@ -711,6 +711,25 @@ function isTerminal(state) {
711
711
  function isClaimedState(state) {
712
712
  return state === "Claimed" || state === "Running" || state === "AwaitApproval";
713
713
  }
714
+ function sameAgentRef(expected, actual) {
715
+ return actual?.agentId === expected.agentId && actual.profileRevision === expected.profileRevision;
716
+ }
717
+ function sameAgentEgressPayload(expected, actual) {
718
+ 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);
719
+ }
720
+ function matchesContentReadReceipt(request, receipt) {
721
+ 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;
722
+ }
723
+ function contentReadCapability(surface) {
724
+ switch (surface) {
725
+ case "workspace":
726
+ return AGENT_CONTENT_WORKSPACE_READ_CAPABILITY;
727
+ case "transcript":
728
+ return AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY;
729
+ case "artifact":
730
+ return AGENT_CONTENT_ARTIFACT_READ_CAPABILITY;
731
+ }
732
+ }
714
733
  var UnknownTaskError = class extends Error {
715
734
  constructor(taskId) {
716
735
  super(`unknown taskId: ${taskId}`);
@@ -788,6 +807,12 @@ var ConnectionHub = class {
788
807
  longPollWaiters = /* @__PURE__ */ new Map();
789
808
  runtimes = /* @__PURE__ */ new Map();
790
809
  serverEvents = new AsyncEventQueue();
810
+ /** First-write-wins reliable facts; the reference composition's bounded in-memory readback. */
811
+ agentEgressReceipts = /* @__PURE__ */ new Map();
812
+ /** Accepted requests are the authority that later receipts/transfers must echo exactly. */
813
+ agentContentReadRequests = /* @__PURE__ */ new Map();
814
+ /** Content-free explicit-read audit facts keyed by exact authenticated device/request identity. */
815
+ agentContentReceipts = /* @__PURE__ */ new Map();
791
816
  /**
792
817
  * Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
793
818
  * reaper's condition (c), see the "task-lease reaper" section below. Reset
@@ -844,12 +869,13 @@ var ConnectionHub = class {
844
869
  * connection this hub never learns capabilities for simply reads back
845
870
  * `undefined` from {@link getDeviceCapabilities}.
846
871
  */
847
- registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets) {
872
+ registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets, clientVersion) {
848
873
  const at = (/* @__PURE__ */ new Date()).toISOString();
849
874
  this.connections.set(deviceId, {
850
875
  ws,
851
876
  connected: true,
852
877
  lastSeen: at,
878
+ clientVersion,
853
879
  runtimes,
854
880
  capabilities,
855
881
  configuredToolsets
@@ -993,9 +1019,10 @@ var ConnectionHub = class {
993
1019
  * taskStore lookup or dedup bookkeeping. See {@link handleRateLimited}
994
1020
  * for what happens on exceed (never a silent drop).
995
1021
  * 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.
1022
+ * server -> daemon type arriving inbound is rejected before it's
1023
+ * dispatched or counted accepted. `conn.hello` is the one non-task
1024
+ * exception, and is accepted only from the bearer-authenticated
1025
+ * long-poll route with an exact device/product/protocol match.
999
1026
  * 2. **ownership (N2)** — an envelope for a task already owned by a
1000
1027
  * *different* device is dropped (logged), never force-failed:
1001
1028
  * force-failing on an authz mismatch would let an attacker who merely
@@ -1015,16 +1042,34 @@ var ConnectionHub = class {
1015
1042
  * wire-level success even though no handler ran a second time; only
1016
1043
  * `rejected`/`rate_limited` (gate steps 0-2) are excluded from that count.
1017
1044
  */
1018
- handleInbound(deviceId, envelope) {
1045
+ handleInbound(deviceId, envelope, authenticatedProductId) {
1019
1046
  this.envelopesInCount++;
1020
1047
  if (!this.rateLimiter.consume(deviceId)) {
1021
1048
  this.handleRateLimited(deviceId);
1022
1049
  return "rate_limited";
1023
1050
  }
1024
1051
  this.rateLimitEventEmittedFor.delete(deviceId);
1052
+ if (envelope.type === "conn.hello") {
1053
+ const payload = envelope.payload;
1054
+ if (authenticatedProductId === void 0 || payload.deviceId !== deviceId || payload.productId !== authenticatedProductId || !payload.protocolVersions.includes(PROTOCOL_VERSION)) {
1055
+ return "rejected";
1056
+ }
1057
+ if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
1058
+ this.dedupDropCount++;
1059
+ return "duplicate";
1060
+ }
1061
+ this.registerLongPollHello(deviceId, payload);
1062
+ return "accepted";
1063
+ }
1025
1064
  if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
1026
1065
  return "rejected";
1027
1066
  }
1067
+ if (envelope.type === "agent.egress.reliable") {
1068
+ return this.handleAgentEgressReliable(deviceId, envelope.payload);
1069
+ }
1070
+ if (envelope.type === "agent.content.receipt") {
1071
+ return this.handleAgentContentReceipt(deviceId, envelope.payload);
1072
+ }
1028
1073
  const taskId = envelope.task_id;
1029
1074
  if (taskId === void 0) return "rejected";
1030
1075
  const record = this.taskStore.get(taskId);
@@ -1039,6 +1084,100 @@ var ConnectionHub = class {
1039
1084
  this.dispatchToHandler(deviceId, taskId, envelope);
1040
1085
  return "accepted";
1041
1086
  }
1087
+ /**
1088
+ * Store before acking. Replays must agree on every identity/cursor/hash
1089
+ * field and receive the original receipt id; a same event id with changed
1090
+ * facts is rejected rather than treated as an update.
1091
+ */
1092
+ handleAgentEgressReliable(deviceId, payload) {
1093
+ if (!this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1094
+ return "rejected";
1095
+ }
1096
+ const key = this.agentEgressReceiptKey(deviceId, payload.eventId);
1097
+ const existing = this.agentEgressReceipts.get(key);
1098
+ if (existing !== void 0) {
1099
+ if (!sameAgentEgressPayload(existing.payload, payload)) return "rejected";
1100
+ this.sendAgentEgressAck(deviceId, existing);
1101
+ this.dedupDropCount++;
1102
+ return "duplicate";
1103
+ }
1104
+ const receipt = {
1105
+ deviceId,
1106
+ payload,
1107
+ receiptId: crypto.randomUUID(),
1108
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
1109
+ };
1110
+ this.agentEgressReceipts.set(key, receipt);
1111
+ this.sendAgentEgressAck(deviceId, receipt);
1112
+ return "accepted";
1113
+ }
1114
+ handleAgentContentReceipt(deviceId, payload) {
1115
+ const capability = contentReadCapability(payload.surface);
1116
+ if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) return "rejected";
1117
+ if (payload.eventId !== payload.requestId) return "rejected";
1118
+ const key = `${deviceId}\0${payload.requestId}`;
1119
+ const request = this.agentContentReadRequests.get(key);
1120
+ if (request === void 0 || !matchesContentReadReceipt(request, payload)) return "rejected";
1121
+ const existing = this.agentContentReceipts.get(key);
1122
+ if (existing !== void 0) {
1123
+ if (JSON.stringify(existing.payload) !== JSON.stringify(payload)) return "rejected";
1124
+ this.sendAgentContentReceiptAck(deviceId, existing);
1125
+ this.dedupDropCount++;
1126
+ return "duplicate";
1127
+ }
1128
+ const receipt = {
1129
+ deviceId,
1130
+ payload,
1131
+ receiptId: payload.requestId,
1132
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
1133
+ };
1134
+ this.agentContentReceipts.set(key, receipt);
1135
+ this.sendAgentContentReceiptAck(deviceId, receipt);
1136
+ return "accepted";
1137
+ }
1138
+ sendAgentEgressAck(deviceId, receipt) {
1139
+ this.sendToDevice(
1140
+ deviceId,
1141
+ "agent.egress.ack",
1142
+ {
1143
+ agentRef: receipt.payload.agentRef,
1144
+ sessionRef: receipt.payload.sessionRef,
1145
+ policyRevision: receipt.payload.policyRevision,
1146
+ eventId: receipt.payload.eventId,
1147
+ cursor: receipt.payload.cursor,
1148
+ receiptId: receipt.receiptId
1149
+ },
1150
+ {}
1151
+ );
1152
+ }
1153
+ sendAgentContentReceiptAck(deviceId, receipt) {
1154
+ this.sendToDevice(
1155
+ deviceId,
1156
+ "agent.egress.ack",
1157
+ {
1158
+ agentRef: receipt.payload.agentRef,
1159
+ sessionRef: receipt.payload.sessionRef,
1160
+ policyRevision: receipt.payload.policyRevision,
1161
+ eventId: receipt.payload.eventId,
1162
+ cursor: receipt.payload.cursor,
1163
+ receiptId: receipt.receiptId
1164
+ },
1165
+ {}
1166
+ );
1167
+ }
1168
+ agentEgressReceiptKey(deviceId, eventId) {
1169
+ return `${deviceId}\0${eventId}`;
1170
+ }
1171
+ /** Record the authenticated long-poll equivalent of the WS opening frame. */
1172
+ registerLongPollHello(deviceId, payload) {
1173
+ this.takeOverAsLongPoll(deviceId);
1174
+ const connection = this.connections.get(deviceId);
1175
+ if (!connection) return;
1176
+ connection.runtimes = payload.runtimes;
1177
+ connection.capabilities = payload.capabilities;
1178
+ connection.configuredToolsets = payload.configuredToolsets;
1179
+ connection.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
1180
+ }
1042
1181
  /**
1043
1182
  * M4 Phase 4 (part A): `deviceId` just exceeded its inbound-envelope rate
1044
1183
  * limit. Never a silent drop: counts the occurrence
@@ -1140,7 +1279,7 @@ var ConnectionHub = class {
1140
1279
  this.onStarted(envelope.task_id, envelope.payload);
1141
1280
  return;
1142
1281
  case "task.decline":
1143
- this.onDecline(envelope.task_id, envelope.payload);
1282
+ this.onDecline(deviceId, envelope.task_id, envelope.payload);
1144
1283
  return;
1145
1284
  case "task.progress":
1146
1285
  this.onProgress(envelope.task_id, envelope.payload);
@@ -1206,6 +1345,14 @@ var ConnectionHub = class {
1206
1345
  onClaim(deviceId, taskId, payload) {
1207
1346
  const record = this.taskStore.get(taskId);
1208
1347
  if (!record) return;
1348
+ if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1349
+ console.warn(`[byok/server] dropping task.claim for ${taskId}: offered to a different device`);
1350
+ return;
1351
+ }
1352
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1353
+ this.forceFailOrDrop(taskId, "task.claim AgentRef does not exactly match the offered AgentRef");
1354
+ return;
1355
+ }
1209
1356
  if (record.state === "Claimed" || record.state === "Running") return;
1210
1357
  this.applyOrFail(taskId, "Claimed", {
1211
1358
  deviceId,
@@ -1230,10 +1377,18 @@ var ConnectionHub = class {
1230
1377
  * ever legal from `Offered`; anything else is stale. Ownership is already
1231
1378
  * enforced by {@link handleInbound} (N2) before this runs.
1232
1379
  */
1233
- onDecline(taskId, payload) {
1380
+ onDecline(deviceId, taskId, payload) {
1234
1381
  const record = this.taskStore.get(taskId);
1235
1382
  if (!record) return;
1236
1383
  if (record.state !== "Offered") return;
1384
+ if (record.deviceId !== void 0 && record.deviceId !== deviceId) {
1385
+ console.warn(`[byok/server] dropping task.decline for ${taskId}: offered to a different device`);
1386
+ return;
1387
+ }
1388
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1389
+ this.forceFailOrDrop(taskId, "task.decline AgentRef does not exactly match the offered AgentRef");
1390
+ return;
1391
+ }
1237
1392
  this.applyOrFail(taskId, "Failed", {
1238
1393
  result: { state: "Failed", reason: payload.reason, retryable: payload.retryable }
1239
1394
  });
@@ -1283,6 +1438,10 @@ var ConnectionHub = class {
1283
1438
  const record = this.taskStore.get(taskId);
1284
1439
  if (!record) return;
1285
1440
  if (isTerminal(record.state)) return;
1441
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1442
+ this.forceFailOrDrop(taskId, "task.complete AgentRef does not exactly match the offered AgentRef");
1443
+ return;
1444
+ }
1286
1445
  this.resumeIfImplicitlyApproved(record);
1287
1446
  const result = {
1288
1447
  state: "Complete",
@@ -1306,6 +1465,10 @@ var ConnectionHub = class {
1306
1465
  const record = this.taskStore.get(taskId);
1307
1466
  if (!record) return;
1308
1467
  if (isTerminal(record.state)) return;
1468
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1469
+ this.forceFailOrDrop(taskId, "task.fail AgentRef does not exactly match the offered AgentRef");
1470
+ return;
1471
+ }
1309
1472
  const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
1310
1473
  this.applyOrFail(taskId, "Failed", { result });
1311
1474
  }
@@ -1324,6 +1487,10 @@ var ConnectionHub = class {
1324
1487
  if (!record) return;
1325
1488
  if (record.state === "Cancelled") return;
1326
1489
  if (isTerminal(record.state)) return;
1490
+ if (record.agentRef && !sameAgentRef(record.agentRef, payload.agentRef)) {
1491
+ this.forceFailOrDrop(taskId, "task.cancelled AgentRef does not exactly match the offered AgentRef");
1492
+ return;
1493
+ }
1327
1494
  this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
1328
1495
  }
1329
1496
  /**
@@ -1682,14 +1849,35 @@ var ConnectionHub = class {
1682
1849
  // dispatch() and the TaskHandle it returns
1683
1850
  // ---------------------------------------------------------------------
1684
1851
  async dispatch(input) {
1852
+ const agentRef = input.agentRef === void 0 ? void 0 : AgentRefSchema.parse(input.agentRef);
1853
+ const egressPolicy = input.egressPolicy === void 0 ? void 0 : AgentEgressPolicySchema.parse(input.egressPolicy);
1685
1854
  const dispatchSelection = input.dispatchSelection === void 0 ? void 0 : DispatchSelectionSchema.parse(input.dispatchSelection);
1686
1855
  const requiredToolsets = input.requiredToolsets === void 0 ? void 0 : RequiredToolsetsSchema.parse(input.requiredToolsets);
1687
1856
  const deviceId = input.deviceId ?? this.pickFirstConnectedDevice(requiredToolsets);
1857
+ if (agentRef !== void 0 && input.deviceId === void 0) {
1858
+ throw new Error("Agent-bound dispatch requires an explicit deviceId for capability admission");
1859
+ }
1860
+ if (egressPolicy !== void 0 && agentRef === void 0) {
1861
+ throw new Error("Agent egress policy requires an explicit AgentRef; legacy task dispatch cannot consume it");
1862
+ }
1863
+ if (egressPolicy !== void 0 && input.sessionRef === void 0) {
1864
+ throw new Error("Agent egress policy requires an exact sessionRef");
1865
+ }
1688
1866
  if (!deviceId || !this.connections.get(deviceId)?.connected) {
1689
1867
  throw new Error(
1690
1868
  deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
1691
1869
  );
1692
1870
  }
1871
+ if (agentRef !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("agent-home-contract") ?? false)) {
1872
+ throw new Error(
1873
+ `device ${deviceId} did not advertise agent-home-contract capability; refusing Agent-bound dispatch`
1874
+ );
1875
+ }
1876
+ if (egressPolicy !== void 0 && !this.hasDeviceCapabilities(deviceId, [AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1877
+ throw new Error(
1878
+ `device ${deviceId} did not advertise Agent egress policy and reliable acknowledgement capabilities; refusing before enqueue`
1879
+ );
1880
+ }
1693
1881
  if (dispatchSelection !== void 0 && !(this.getDeviceCapabilities(deviceId)?.includes("dispatch-selection") ?? false)) {
1694
1882
  throw new Error(
1695
1883
  `device ${deviceId} did not advertise dispatch-selection capability; refusing authoritative provider/model dispatch`
@@ -1730,7 +1918,8 @@ var ConnectionHub = class {
1730
1918
  policy,
1731
1919
  requiredToolsets,
1732
1920
  deviceId,
1733
- sessionRef: input.sessionRef
1921
+ sessionRef: input.sessionRef,
1922
+ agentRef
1734
1923
  });
1735
1924
  const queue = new AsyncEventQueue();
1736
1925
  let resolveResult;
@@ -1747,7 +1936,27 @@ var ConnectionHub = class {
1747
1936
  dispatchSelection,
1748
1937
  sessionRef: input.sessionRef
1749
1938
  };
1750
- if (requiredToolsets === void 0) {
1939
+ if (agentRef !== void 0 && egressPolicy !== void 0) {
1940
+ this.sendToDevice(
1941
+ deviceId,
1942
+ "task.offer_for_agent_with_egress",
1943
+ {
1944
+ ...commonOffer,
1945
+ sessionRef: input.sessionRef,
1946
+ agentRef,
1947
+ egressPolicy,
1948
+ ...requiredToolsets === void 0 ? {} : { requiredToolsets }
1949
+ },
1950
+ { taskId, sessionRef: input.sessionRef }
1951
+ );
1952
+ } else if (agentRef !== void 0) {
1953
+ this.sendToDevice(
1954
+ deviceId,
1955
+ "task.offer_for_agent",
1956
+ { ...commonOffer, agentRef, ...requiredToolsets === void 0 ? {} : { requiredToolsets } },
1957
+ { taskId, sessionRef: input.sessionRef }
1958
+ );
1959
+ } else if (requiredToolsets === void 0) {
1751
1960
  this.sendToDevice(deviceId, "task.offer", commonOffer, { taskId, sessionRef: input.sessionRef });
1752
1961
  } else {
1753
1962
  this.sendToDevice(
@@ -1759,6 +1968,27 @@ var ConnectionHub = class {
1759
1968
  }
1760
1969
  return this.buildTaskHandle(taskId);
1761
1970
  }
1971
+ /** Capability-gated control-plane read request; no request enters the outbox on omission. */
1972
+ async requestAgentContentRead(input) {
1973
+ const payload = AgentContentReadPayloadSchema.parse(input.payload);
1974
+ const deviceId = input.deviceId;
1975
+ if (!this.connections.get(deviceId)?.connected) {
1976
+ throw new Error(`device ${deviceId} is not connected`);
1977
+ }
1978
+ const capability = contentReadCapability(payload.surface);
1979
+ if (!this.hasDeviceCapabilities(deviceId, [capability, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY])) {
1980
+ throw new Error(
1981
+ `device ${deviceId} did not advertise ${capability} and reliable acknowledgement support; refusing Agent content read before enqueue`
1982
+ );
1983
+ }
1984
+ const key = `${deviceId}\0${payload.requestId}`;
1985
+ const existing = this.agentContentReadRequests.get(key);
1986
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(payload)) {
1987
+ throw new Error(`Agent content request ${payload.requestId} already exists with a different immutable body`);
1988
+ }
1989
+ this.agentContentReadRequests.set(key, payload);
1990
+ this.sendToDevice(deviceId, "agent.content.read", payload, {});
1991
+ }
1762
1992
  buildTaskHandle(taskId) {
1763
1993
  const hub = this;
1764
1994
  return {
@@ -1887,8 +2117,7 @@ var ConnectionHub = class {
1887
2117
  * not {@link getDeviceCapabilities}, not `ConnectionState.runtimes`, and
1888
2118
  * with no fallback to either when the snapshot is absent. See
1889
2119
  * {@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).
2120
+ * in scope (it describes a daemon build, not this task's runtime).
1892
2121
  */
1893
2122
  async steerTask(taskId, text) {
1894
2123
  const record = this.taskStore.get(taskId);
@@ -1941,7 +2170,8 @@ var ConnectionHub = class {
1941
2170
  const envelope = createEnvelope(type, payload, combinedOpts);
1942
2171
  const taskId = opts.taskId;
1943
2172
  const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
1944
- outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal });
2173
+ const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read";
2174
+ outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
1945
2175
  if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
1946
2176
  this.deliverToDevice(deviceId, envelope);
1947
2177
  return envelope;
@@ -1966,7 +2196,7 @@ var ConnectionHub = class {
1966
2196
  const outbox = this.outboxes.get(deviceId);
1967
2197
  if (!outbox) return [];
1968
2198
  return outbox.ring.filter(
1969
- (entry) => entry.seq > cursor && entry.taskId !== void 0 && (!this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
2199
+ (entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
1970
2200
  ).map((entry) => entry.envelope);
1971
2201
  }
1972
2202
  isTaskTerminal(taskId) {
@@ -1997,6 +2227,7 @@ var ConnectionHub = class {
1997
2227
  deviceName,
1998
2228
  connected: conn?.connected ?? false,
1999
2229
  lastSeen: conn?.lastSeen,
2230
+ ...conn?.clientVersion === void 0 ? {} : { clientVersion: conn.clientVersion },
2000
2231
  runtimes: conn?.runtimes,
2001
2232
  configuredToolsets: conn?.configuredToolsets ? [...conn.configuredToolsets] : void 0
2002
2233
  };
@@ -2018,6 +2249,13 @@ var ConnectionHub = class {
2018
2249
  getDeviceCapabilities(deviceId) {
2019
2250
  return this.connections.get(deviceId)?.capabilities;
2020
2251
  }
2252
+ hasDeviceCapabilities(deviceId, required) {
2253
+ const advertised = this.getDeviceCapabilities(deviceId);
2254
+ return advertised !== void 0 && required.every((capability) => advertised.includes(capability));
2255
+ }
2256
+ getAgentEgressReceipt(deviceId, eventId) {
2257
+ return this.agentEgressReceipts.get(this.agentEgressReceiptKey(deviceId, eventId));
2258
+ }
2021
2259
  getTask(taskId) {
2022
2260
  return this.taskStore.get(taskId);
2023
2261
  }
@@ -2081,6 +2319,7 @@ var InMemoryTaskStore = class {
2081
2319
  requiredToolsets: input.requiredToolsets,
2082
2320
  deviceId: input.deviceId,
2083
2321
  sessionRef: input.sessionRef,
2322
+ agentRef: input.agentRef,
2084
2323
  createdAt: now,
2085
2324
  updatedAt: now
2086
2325
  };
@@ -2251,7 +2490,8 @@ function handleConnection(ws, principal, deps) {
2251
2490
  ws,
2252
2491
  payload.runtimes,
2253
2492
  payload.capabilities,
2254
- payload.configuredToolsets
2493
+ payload.configuredToolsets,
2494
+ payload.clientVersion
2255
2495
  );
2256
2496
  deps.hub.sendConnAck(deviceId, SUPPORTED_CAPABILITIES);
2257
2497
  if (payload.cursor !== void 0) {
@@ -2365,6 +2605,7 @@ CREATE TABLE IF NOT EXISTS tasks (
2365
2605
  policy_json TEXT NOT NULL,
2366
2606
  device_id TEXT,
2367
2607
  session_ref TEXT,
2608
+ agent_ref_json TEXT,
2368
2609
  created_at TEXT NOT NULL,
2369
2610
  updated_at TEXT NOT NULL,
2370
2611
  result_json TEXT,
@@ -2385,7 +2626,8 @@ var ADDITIVE_COLUMNS = [
2385
2626
  {
2386
2627
  name: "claimed_runtime_capabilities_json",
2387
2628
  ddl: "ALTER TABLE tasks ADD COLUMN claimed_runtime_capabilities_json TEXT"
2388
- }
2629
+ },
2630
+ { name: "agent_ref_json", ddl: "ALTER TABLE tasks ADD COLUMN agent_ref_json TEXT" }
2389
2631
  ];
2390
2632
  function currentTaskColumns(db) {
2391
2633
  return new Set(db.prepare("PRAGMA table_info(tasks)").all().map((c) => c.name));
@@ -2411,6 +2653,7 @@ function rowToRecord(row) {
2411
2653
  const resultJson = row.result_json;
2412
2654
  const requiredToolsetsJson = row.required_toolsets_json;
2413
2655
  const claimedRuntimeCapabilitiesJson = row.claimed_runtime_capabilities_json;
2656
+ const agentRefJson = row.agent_ref_json;
2414
2657
  return {
2415
2658
  taskId: row.task_id,
2416
2659
  state: row.state,
@@ -2420,6 +2663,7 @@ function rowToRecord(row) {
2420
2663
  policy: JSON.parse(row.policy_json),
2421
2664
  deviceId: row.device_id ?? void 0,
2422
2665
  sessionRef: row.session_ref ?? void 0,
2666
+ agentRef: agentRefJson ? JSON.parse(agentRefJson) : void 0,
2423
2667
  createdAt: row.created_at,
2424
2668
  updatedAt: row.updated_at,
2425
2669
  result: resultJson ? JSON.parse(resultJson) : void 0,
@@ -2444,13 +2688,13 @@ var SqliteTaskStore = class {
2444
2688
  secureSqliteFilePermissions(opts.path);
2445
2689
  this.insertStmt = this.db.prepare(
2446
2690
  `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2691
+ (task_id, state, instruction, runtime, required_toolsets_json, policy_json, device_id, session_ref, agent_ref_json, created_at, updated_at, result_json)
2692
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2449
2693
  );
2450
2694
  this.updateStmt = this.db.prepare(
2451
2695
  `UPDATE tasks SET
2452
2696
  state = ?, instruction = ?, runtime = ?, required_toolsets_json = ?, policy_json = ?, device_id = ?,
2453
- session_ref = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2697
+ session_ref = ?, agent_ref_json = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2454
2698
  claimed_runtime = ?, claimed_runtime_capabilities_json = ?
2455
2699
  WHERE task_id = ? AND state = ?`
2456
2700
  );
@@ -2478,6 +2722,7 @@ var SqliteTaskStore = class {
2478
2722
  policy: input.policy,
2479
2723
  deviceId: input.deviceId,
2480
2724
  sessionRef: input.sessionRef,
2725
+ agentRef: input.agentRef,
2481
2726
  createdAt: now,
2482
2727
  updatedAt: now
2483
2728
  };
@@ -2490,6 +2735,7 @@ var SqliteTaskStore = class {
2490
2735
  JSON.stringify(record.policy),
2491
2736
  record.deviceId ?? null,
2492
2737
  record.sessionRef ?? null,
2738
+ record.agentRef ? JSON.stringify(record.agentRef) : null,
2493
2739
  record.createdAt,
2494
2740
  record.updatedAt,
2495
2741
  record.result ? JSON.stringify(record.result) : null
@@ -2548,6 +2794,7 @@ var SqliteTaskStore = class {
2548
2794
  JSON.stringify(updated.policy),
2549
2795
  updated.deviceId ?? null,
2550
2796
  updated.sessionRef ?? null,
2797
+ updated.agentRef ? JSON.stringify(updated.agentRef) : null,
2551
2798
  updated.createdAt,
2552
2799
  updated.updatedAt,
2553
2800
  updated.result ? JSON.stringify(updated.result) : null,
@@ -2780,10 +3027,14 @@ function createByokServer(opts) {
2780
3027
  createPairingCode: (claims) => pairing.createPairingCode(claims)
2781
3028
  },
2782
3029
  dispatch: (input) => hub.dispatch(input),
3030
+ requestAgentContentRead: (input) => hub.requestAgentContentRead(input),
2783
3031
  tasks: {
2784
3032
  get: (taskId) => hub.getTask(taskId),
2785
3033
  list: () => hub.listTasks()
2786
3034
  },
3035
+ egress: {
3036
+ get: (deviceId, eventId) => hub.getAgentEgressReceipt(deviceId, eventId)
3037
+ },
2787
3038
  machines: {
2788
3039
  list: () => hub.listMachines()
2789
3040
  },