@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/hub.d.ts +37 -18
- package/dist/index.d.ts +8 -2
- package/dist/index.js +288 -29
- package/dist/index.js.map +1 -1
- package/dist/task-store.d.ts +2 -1
- package/dist/types.d.ts +26 -3
- 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, 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
|
-
|
|
324
|
-
|
|
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:
|
|
372
|
-
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
|
|
997
|
-
* `conn.hello`
|
|
998
|
-
*
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
},
|