@agentproto/pair-client 0.2.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/LICENSE +202 -0
- package/README.md +137 -0
- package/dist/index.d.ts +307 -0
- package/dist/index.mjs +776 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +69 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { MAX_FRAME_PAYLOAD_BYTES, encodeData, clientHandshakeOverSink, decodeData, wrapWebSocket, splitPayload } from '@agentproto/acp/tunnel/browser';
|
|
2
|
+
import { PAIR_VERSION, deriveOfferTokens, derivePairRoot, importPairRootKey, parseOfferUrl, PairingError, startClientHandshake, encodePairingMessage, decodePairingReply, currentEpoch, deriveEpochTokens } from '@agentproto/secrets/pairing/browser';
|
|
3
|
+
export { DEFAULT_PAIR_PAGE, PAIR_PAGE_FP_PLACEHOLDER, PAIR_WEB_URL, PAIR_WEB_URL_TEMPLATE_CLOUD, encodeOfferWebUrl, expectedPairHost, resolvePairPageUrl } from '@agentproto/secrets/pairing/browser';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @agentproto/pair-client
|
|
7
|
+
* Browser-safe client for agentproto E2E daemon pairing — pair from an offer,
|
|
8
|
+
* then fetch through the daemon over an end-to-end encrypted rendezvous tunnel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// src/errors.ts
|
|
13
|
+
var TunnelClientError = class extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = "TunnelClientError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function revokedError(daemonName) {
|
|
22
|
+
return new TunnelClientError(
|
|
23
|
+
"revoked",
|
|
24
|
+
`this device was unpaired from ${daemonName}; scan a new pairing QR`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
function outdatedError(what, options) {
|
|
28
|
+
return new TunnelClientError(
|
|
29
|
+
"protocol_outdated",
|
|
30
|
+
`${what} uses the retired pair/v1 protocol; upgrade agentproto on the daemon and scan a new pairing QR (\`agentproto pair offer --qr\`)`,
|
|
31
|
+
options
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function errMsg(err) {
|
|
35
|
+
return err instanceof Error ? err.message : String(err);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/channel.ts
|
|
39
|
+
var PAIRING_REVOKED_CODE = "pairing_revoked";
|
|
40
|
+
var PAIRING_PROTOCOL_OUTDATED_CODE = "pairing_protocol_outdated";
|
|
41
|
+
var DEFAULT_DIAL_TIMEOUT_MS = 15e3;
|
|
42
|
+
var DEFAULT_HANDSHAKE_TIMEOUT_MS = 15e3;
|
|
43
|
+
var DEFAULT_GREETING_TIMEOUT_MS = 1e4;
|
|
44
|
+
function rendezvousClientUrl(base, token) {
|
|
45
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
46
|
+
return `${base}${sep}side=client&t=${encodeURIComponent(token)}`;
|
|
47
|
+
}
|
|
48
|
+
function dialRendezvous(rendezvousUrl, token, WebSocketImpl, timeoutMs) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let ws;
|
|
51
|
+
try {
|
|
52
|
+
ws = new WebSocketImpl(rendezvousClientUrl(rendezvousUrl, token));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
reject(err);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finish = (fn) => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
settled = true;
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
ws.removeEventListener("open", onOpen);
|
|
63
|
+
ws.removeEventListener("error", onFail);
|
|
64
|
+
ws.removeEventListener("close", onFail);
|
|
65
|
+
fn();
|
|
66
|
+
};
|
|
67
|
+
const onOpen = () => finish(() => {
|
|
68
|
+
if (ws.readyState !== 1) reject(new Error("rendezvous closed the connection on arrival"));
|
|
69
|
+
else resolve(wrapWebSocket(ws));
|
|
70
|
+
});
|
|
71
|
+
const onFail = () => finish(() => reject(new Error("rendezvous dial failed")));
|
|
72
|
+
const timer = setTimeout(
|
|
73
|
+
() => finish(() => {
|
|
74
|
+
try {
|
|
75
|
+
ws.close();
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
reject(new Error(`rendezvous dial timed out after ${timeoutMs}ms`));
|
|
79
|
+
}),
|
|
80
|
+
timeoutMs
|
|
81
|
+
);
|
|
82
|
+
ws.addEventListener("open", onOpen);
|
|
83
|
+
ws.addEventListener("error", onFail);
|
|
84
|
+
ws.addEventListener("close", onFail);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function openChannel(params) {
|
|
88
|
+
const raw = await dialRendezvous(
|
|
89
|
+
params.rendezvousUrl,
|
|
90
|
+
params.route,
|
|
91
|
+
params.WebSocket,
|
|
92
|
+
params.dialTimeoutMs ?? DEFAULT_DIAL_TIMEOUT_MS
|
|
93
|
+
);
|
|
94
|
+
let sink;
|
|
95
|
+
let session = null;
|
|
96
|
+
try {
|
|
97
|
+
const started = await startClientHandshake({
|
|
98
|
+
daemonX25519Pub: params.daemonX25519Pub,
|
|
99
|
+
daemonEd25519Pub: params.daemonEd25519Pub,
|
|
100
|
+
authToken: params.auth,
|
|
101
|
+
clientName: params.clientName
|
|
102
|
+
});
|
|
103
|
+
sink = await clientHandshakeOverSink(
|
|
104
|
+
raw,
|
|
105
|
+
encodePairingMessage(started.hello),
|
|
106
|
+
async (reply) => {
|
|
107
|
+
session = await started.complete(decodePairingReply(reply));
|
|
108
|
+
return session;
|
|
109
|
+
},
|
|
110
|
+
{ timeoutMs: params.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS }
|
|
111
|
+
);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
raw.close("handshake failed");
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
if (!session) {
|
|
117
|
+
sink.close("handshake failed");
|
|
118
|
+
throw new Error("handshake did not derive a session");
|
|
119
|
+
}
|
|
120
|
+
let router = null;
|
|
121
|
+
const backlog = [];
|
|
122
|
+
let onGreeting = null;
|
|
123
|
+
sink.onFrame((frame) => {
|
|
124
|
+
if (onGreeting) {
|
|
125
|
+
const cb = onGreeting;
|
|
126
|
+
onGreeting = null;
|
|
127
|
+
cb(frame);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (router) router(frame);
|
|
131
|
+
else backlog.push(frame);
|
|
132
|
+
});
|
|
133
|
+
const timeoutMs = params.greetingTimeoutMs ?? DEFAULT_GREETING_TIMEOUT_MS;
|
|
134
|
+
let greeting;
|
|
135
|
+
try {
|
|
136
|
+
greeting = await new Promise((resolve, reject) => {
|
|
137
|
+
const timer = setTimeout(() => reject(new Error("no greeting from the daemon")), timeoutMs);
|
|
138
|
+
const offClose = sink.onClose((reason) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
reject(new Error(`channel closed before the daemon greeted: ${reason ?? "closed"}`));
|
|
141
|
+
});
|
|
142
|
+
onGreeting = (frame) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
offClose();
|
|
145
|
+
if (frame.t === "hello") resolve({ kind: "hello", hello: frame });
|
|
146
|
+
else if (frame.t === "error" && frame.code === PAIRING_REVOKED_CODE)
|
|
147
|
+
resolve({ kind: "revoked", frame });
|
|
148
|
+
else if (frame.t === "error" && frame.code === PAIRING_PROTOCOL_OUTDATED_CODE)
|
|
149
|
+
resolve({ kind: "outdated", frame });
|
|
150
|
+
else reject(new Error(`unexpected first frame from the daemon: "${frame.t}"`));
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
sink.close("no greeting");
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
sink,
|
|
159
|
+
session,
|
|
160
|
+
greeting,
|
|
161
|
+
route(handler) {
|
|
162
|
+
router = handler;
|
|
163
|
+
for (const f of backlog.splice(0)) handler(f);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function daemonNameFromHello(hello, fallback) {
|
|
168
|
+
if (hello?.label) return hello.label;
|
|
169
|
+
const platform = hello?.daemon?.platform;
|
|
170
|
+
const host = platform?.includes("/") ? platform.slice(platform.indexOf("/") + 1) : void 0;
|
|
171
|
+
return host || fallback;
|
|
172
|
+
}
|
|
173
|
+
function defaultWebSocket() {
|
|
174
|
+
const ctor = globalThis.WebSocket;
|
|
175
|
+
if (!ctor) {
|
|
176
|
+
throw new TunnelClientError("offline", "no WebSocket implementation in this context (pass opts.WebSocket)");
|
|
177
|
+
}
|
|
178
|
+
return ctor;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/client.ts
|
|
182
|
+
var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
183
|
+
var DROP_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["connection", "transfer-encoding", "content-encoding", "content-length", "keep-alive"]);
|
|
184
|
+
var BASE = "http://tunnel.invalid";
|
|
185
|
+
function connect(credential, opts = {}) {
|
|
186
|
+
const now = opts.now ?? Date.now;
|
|
187
|
+
const random = opts.random ?? Math.random;
|
|
188
|
+
const WebSocketImpl = opts.WebSocket ?? defaultWebSocket();
|
|
189
|
+
const minMs = opts.reconnectMinMs ?? 500;
|
|
190
|
+
const maxMs = opts.reconnectMaxMs ?? 3e4;
|
|
191
|
+
const requestWaitMs = opts.requestWaitMs ?? 2e4;
|
|
192
|
+
const keepAliveMs = opts.keepAliveMs ?? 25e3;
|
|
193
|
+
const pongTimeoutMs = opts.pongTimeoutMs ?? 1e4;
|
|
194
|
+
const mapPath = opts.mapPath ?? ((url) => `${url.pathname}${url.search}`);
|
|
195
|
+
const reqPrefix = Math.floor(random() * 4294967296).toString(36);
|
|
196
|
+
let state = "connecting";
|
|
197
|
+
let lastError = null;
|
|
198
|
+
let hello = null;
|
|
199
|
+
let live = null;
|
|
200
|
+
let stopped = false;
|
|
201
|
+
let seq = 0;
|
|
202
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
203
|
+
let wake = null;
|
|
204
|
+
const notify = (change) => {
|
|
205
|
+
for (const l of [...listeners]) {
|
|
206
|
+
try {
|
|
207
|
+
l(change);
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const setState = (next, error) => {
|
|
213
|
+
if (error) lastError = error;
|
|
214
|
+
if (next === state && !error) return;
|
|
215
|
+
state = next;
|
|
216
|
+
notify(error ? { state, error } : { state });
|
|
217
|
+
};
|
|
218
|
+
const failPending = (l, err) => {
|
|
219
|
+
for (const [, p] of l.pending) {
|
|
220
|
+
p.detach();
|
|
221
|
+
if (!p.settled) {
|
|
222
|
+
p.settled = true;
|
|
223
|
+
p.reject(err);
|
|
224
|
+
} else if (p.controller) {
|
|
225
|
+
try {
|
|
226
|
+
p.controller.error(err);
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
l.pending.clear();
|
|
232
|
+
};
|
|
233
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
234
|
+
const timer = setTimeout(done, ms);
|
|
235
|
+
function done() {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
wake = null;
|
|
238
|
+
resolve();
|
|
239
|
+
}
|
|
240
|
+
wake = done;
|
|
241
|
+
});
|
|
242
|
+
async function establish() {
|
|
243
|
+
const epoch = currentEpoch(now());
|
|
244
|
+
let lastErr;
|
|
245
|
+
for (const e of [epoch, epoch - 1]) {
|
|
246
|
+
if (stopped) break;
|
|
247
|
+
try {
|
|
248
|
+
const { route, auth } = await deriveEpochTokens(credential.pairRoot, e);
|
|
249
|
+
const ch = await openChannel({
|
|
250
|
+
rendezvousUrl: credential.rendezvousUrl,
|
|
251
|
+
route,
|
|
252
|
+
auth,
|
|
253
|
+
daemonX25519Pub: credential.daemonX25519Pub,
|
|
254
|
+
daemonEd25519Pub: credential.daemonEd25519Pub,
|
|
255
|
+
clientName: credential.clientName,
|
|
256
|
+
WebSocket: WebSocketImpl,
|
|
257
|
+
...opts.dialTimeoutMs !== void 0 ? { dialTimeoutMs: opts.dialTimeoutMs } : {},
|
|
258
|
+
...opts.handshakeTimeoutMs !== void 0 ? { handshakeTimeoutMs: opts.handshakeTimeoutMs } : {},
|
|
259
|
+
...opts.greetingTimeoutMs !== void 0 ? { greetingTimeoutMs: opts.greetingTimeoutMs } : {}
|
|
260
|
+
});
|
|
261
|
+
if (ch.greeting.kind === "revoked") {
|
|
262
|
+
ch.sink.close("revoked");
|
|
263
|
+
throw revokedError(credential.name);
|
|
264
|
+
}
|
|
265
|
+
if (ch.greeting.kind === "outdated") {
|
|
266
|
+
ch.sink.close("outdated");
|
|
267
|
+
throw outdatedError(`this device's pairing with ${credential.name}`);
|
|
268
|
+
}
|
|
269
|
+
return { sink: ch.sink, hello: ch.greeting.hello, route: ch.route };
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err instanceof TunnelClientError && (err.code === "revoked" || err.code === "protocol_outdated")) throw err;
|
|
272
|
+
lastErr = err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw new TunnelClientError(
|
|
276
|
+
"offline",
|
|
277
|
+
`could not reach ${credential.name} via ${credential.rendezvousUrl}: ${errMsg(lastErr)}`,
|
|
278
|
+
{ cause: lastErr }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
function serve(ch) {
|
|
282
|
+
const l = { sink: ch.sink, pending: /* @__PURE__ */ new Map() };
|
|
283
|
+
live = l;
|
|
284
|
+
hello = ch.hello;
|
|
285
|
+
let revoked = null;
|
|
286
|
+
let pingTimer = null;
|
|
287
|
+
let pongTimer = null;
|
|
288
|
+
let pingSeq = 0;
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
ch.route((frame) => {
|
|
291
|
+
switch (frame.t) {
|
|
292
|
+
case "http_response":
|
|
293
|
+
case "http_response_head":
|
|
294
|
+
case "http_response_chunk":
|
|
295
|
+
onHttpFrame(l, frame);
|
|
296
|
+
return;
|
|
297
|
+
case "pong":
|
|
298
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
299
|
+
pongTimer = null;
|
|
300
|
+
return;
|
|
301
|
+
case "ping":
|
|
302
|
+
l.sink.send({ t: "pong", nonce: frame.nonce });
|
|
303
|
+
return;
|
|
304
|
+
case "error":
|
|
305
|
+
if (frame.code === PAIRING_REVOKED_CODE) {
|
|
306
|
+
revoked = revokedError(credential.name);
|
|
307
|
+
l.sink.close("revoked");
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
default:
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
if (keepAliveMs > 0) {
|
|
315
|
+
pingTimer = setInterval(() => {
|
|
316
|
+
if (pongTimer) return;
|
|
317
|
+
l.sink.send({ t: "ping", nonce: `ka-${++pingSeq}` });
|
|
318
|
+
pongTimer = setTimeout(() => l.sink.close("keepalive timeout"), pongTimeoutMs);
|
|
319
|
+
}, keepAliveMs);
|
|
320
|
+
}
|
|
321
|
+
l.sink.onClose(() => {
|
|
322
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
323
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
324
|
+
if (live === l) live = null;
|
|
325
|
+
failPending(
|
|
326
|
+
l,
|
|
327
|
+
revoked ?? (stopped ? new TunnelClientError("closed", "the tunnel client was closed") : new TunnelClientError("disconnected", `lost the connection to ${credential.name}`))
|
|
328
|
+
);
|
|
329
|
+
resolve(revoked);
|
|
330
|
+
});
|
|
331
|
+
if (!l.sink.isOpen) {
|
|
332
|
+
failPending(l, new TunnelClientError("disconnected", `lost the connection to ${credential.name}`));
|
|
333
|
+
resolve(revoked);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async function run() {
|
|
338
|
+
let attempt = 0;
|
|
339
|
+
while (!stopped) {
|
|
340
|
+
setState("connecting");
|
|
341
|
+
let ch;
|
|
342
|
+
try {
|
|
343
|
+
ch = await establish();
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (stopped) return;
|
|
346
|
+
const e = err instanceof TunnelClientError ? err : new TunnelClientError("offline", errMsg(err), { cause: err });
|
|
347
|
+
if (e.code === "revoked") {
|
|
348
|
+
setState("revoked", e);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (e.code === "protocol_outdated") {
|
|
352
|
+
setState("outdated", e);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
setState("offline", e);
|
|
356
|
+
await sleep(backoff(attempt++));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (stopped) {
|
|
360
|
+
ch.sink.close("client closed");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
attempt = 0;
|
|
364
|
+
const closed = serve(ch);
|
|
365
|
+
setState("open");
|
|
366
|
+
if (opts.store) {
|
|
367
|
+
void opts.store.put({ ...credential, lastSeen: new Date(now()).toISOString() }).catch(() => {
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
const revoked = await closed;
|
|
371
|
+
if (stopped) return;
|
|
372
|
+
if (revoked) {
|
|
373
|
+
setState("revoked", revoked);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
setState("offline", new TunnelClientError("disconnected", `lost the connection to ${credential.name}`));
|
|
377
|
+
await sleep(backoff(attempt++));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function backoff(n) {
|
|
381
|
+
const cap = Math.min(maxMs, minMs * 2 ** Math.min(n, 30));
|
|
382
|
+
return cap / 2 + random() * (cap / 2);
|
|
383
|
+
}
|
|
384
|
+
function onHttpFrame(l, frame) {
|
|
385
|
+
const p = l.pending.get(frame.reqId);
|
|
386
|
+
if (!p) return;
|
|
387
|
+
if (frame.t === "http_response") {
|
|
388
|
+
l.pending.delete(frame.reqId);
|
|
389
|
+
p.detach();
|
|
390
|
+
if (p.settled) return;
|
|
391
|
+
p.settled = true;
|
|
392
|
+
let body = frame.body ? decodeData(frame.body) : null;
|
|
393
|
+
if (!body?.length && frame.error) {
|
|
394
|
+
body = new TextEncoder().encode(JSON.stringify({ error: frame.error.code, message: frame.error.message }));
|
|
395
|
+
}
|
|
396
|
+
p.resolve(makeResponse(frame.status, frame.headers, body));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (frame.t === "http_response_head") {
|
|
400
|
+
if (p.settled) return;
|
|
401
|
+
p.settled = true;
|
|
402
|
+
const stream = new ReadableStream({
|
|
403
|
+
start: (c2) => {
|
|
404
|
+
p.controller = c2;
|
|
405
|
+
},
|
|
406
|
+
cancel: () => {
|
|
407
|
+
if (l.pending.get(frame.reqId) === p) {
|
|
408
|
+
l.pending.delete(frame.reqId);
|
|
409
|
+
p.detach();
|
|
410
|
+
l.sink.send({ t: "http_cancel", reqId: frame.reqId });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
p.resolve(makeResponse(frame.status, frame.headers, stream));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const c = p.controller;
|
|
418
|
+
if (!c) return;
|
|
419
|
+
try {
|
|
420
|
+
if (frame.data) c.enqueue(decodeData(frame.data));
|
|
421
|
+
if (frame.error) {
|
|
422
|
+
l.pending.delete(frame.reqId);
|
|
423
|
+
p.detach();
|
|
424
|
+
c.error(new TunnelClientError("stream_error", `${frame.error.code}: ${frame.error.message}`));
|
|
425
|
+
} else if (frame.end) {
|
|
426
|
+
l.pending.delete(frame.reqId);
|
|
427
|
+
p.detach();
|
|
428
|
+
c.close();
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function waitForLive(signal) {
|
|
434
|
+
if (state === "open" && live) return Promise.resolve(live);
|
|
435
|
+
if (state === "revoked") return Promise.reject(lastError ?? revokedError(credential.name));
|
|
436
|
+
if (state === "outdated") return Promise.reject(lastError ?? outdatedError(`this device's pairing with ${credential.name}`));
|
|
437
|
+
if (state === "closed") return Promise.reject(new TunnelClientError("closed", "the tunnel client was closed"));
|
|
438
|
+
return new Promise((resolve, reject) => {
|
|
439
|
+
const done = (fn) => {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
off();
|
|
442
|
+
signal.removeEventListener("abort", onAbort);
|
|
443
|
+
fn();
|
|
444
|
+
};
|
|
445
|
+
const timer = setTimeout(
|
|
446
|
+
() => done(
|
|
447
|
+
() => reject(
|
|
448
|
+
new TunnelClientError(
|
|
449
|
+
"offline",
|
|
450
|
+
`${credential.name} is unreachable (no connection within ${requestWaitMs}ms)` + (lastError ? `: ${lastError.message}` : ""),
|
|
451
|
+
lastError ? { cause: lastError } : void 0
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
),
|
|
455
|
+
requestWaitMs
|
|
456
|
+
);
|
|
457
|
+
const onAbort = () => done(() => reject(signal.reason));
|
|
458
|
+
signal.addEventListener("abort", onAbort);
|
|
459
|
+
const off = onStateChangeImpl(({ state: s, error }) => {
|
|
460
|
+
if (s === "open" && live) {
|
|
461
|
+
const l = live;
|
|
462
|
+
done(() => resolve(l));
|
|
463
|
+
} else if (s === "revoked" || s === "outdated" || s === "closed") {
|
|
464
|
+
done(
|
|
465
|
+
() => reject(
|
|
466
|
+
error ?? new TunnelClientError(s === "outdated" ? "protocol_outdated" : s, `the tunnel client is ${s}`)
|
|
467
|
+
)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async function tunnelFetch(input, init) {
|
|
474
|
+
const request = input instanceof Request ? init ? new Request(input, init) : input : new Request(new URL(String(input), BASE), init);
|
|
475
|
+
const signal = request.signal;
|
|
476
|
+
if (signal.aborted) throw signal.reason;
|
|
477
|
+
const method = request.method.toUpperCase();
|
|
478
|
+
const body = method === "GET" || method === "HEAD" ? null : new Uint8Array(await request.arrayBuffer());
|
|
479
|
+
const headers = {};
|
|
480
|
+
request.headers.forEach((value, key) => {
|
|
481
|
+
headers[key] = value;
|
|
482
|
+
});
|
|
483
|
+
const path = mapPath(new URL(request.url));
|
|
484
|
+
if (!path.startsWith("/")) throw new TypeError(`tunnel path must start with "/" (got "${path}")`);
|
|
485
|
+
const l = await waitForLive(signal);
|
|
486
|
+
if (signal.aborted) throw signal.reason;
|
|
487
|
+
if (!l.sink.isOpen) {
|
|
488
|
+
throw new TunnelClientError("disconnected", `lost the connection to ${credential.name}`);
|
|
489
|
+
}
|
|
490
|
+
const reqId = `${reqPrefix}-${(++seq).toString(36)}`;
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const onAbort = () => {
|
|
493
|
+
const p = l.pending.get(reqId);
|
|
494
|
+
if (!p) return;
|
|
495
|
+
l.pending.delete(reqId);
|
|
496
|
+
p.detach();
|
|
497
|
+
l.sink.send({ t: "http_cancel", reqId });
|
|
498
|
+
if (!p.settled) {
|
|
499
|
+
p.settled = true;
|
|
500
|
+
reject(signal.reason);
|
|
501
|
+
} else {
|
|
502
|
+
try {
|
|
503
|
+
p.controller?.error(signal.reason);
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const pending = {
|
|
509
|
+
resolve,
|
|
510
|
+
reject,
|
|
511
|
+
controller: null,
|
|
512
|
+
settled: false,
|
|
513
|
+
detach: () => signal.removeEventListener("abort", onAbort)
|
|
514
|
+
};
|
|
515
|
+
l.pending.set(reqId, pending);
|
|
516
|
+
signal.addEventListener("abort", onAbort);
|
|
517
|
+
const base = {
|
|
518
|
+
t: "http_request",
|
|
519
|
+
reqId,
|
|
520
|
+
method,
|
|
521
|
+
path,
|
|
522
|
+
...Object.keys(headers).length ? { headers } : {}
|
|
523
|
+
};
|
|
524
|
+
if (body && body.length > MAX_FRAME_PAYLOAD_BYTES && hello?.capabilities.httpRequestChunks === true) {
|
|
525
|
+
l.sink.send({ ...base, bodyChunked: true });
|
|
526
|
+
const pieces = splitPayload(body);
|
|
527
|
+
pieces.forEach((piece, i) => {
|
|
528
|
+
l.sink.send({
|
|
529
|
+
t: "http_request_chunk",
|
|
530
|
+
reqId,
|
|
531
|
+
data: encodeData(piece),
|
|
532
|
+
...i === pieces.length - 1 ? { end: true } : {}
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
} else {
|
|
536
|
+
l.sink.send({ ...base, ...body && body.length ? { body: encodeData(body) } : {} });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function onStateChangeImpl(listener) {
|
|
541
|
+
listeners.add(listener);
|
|
542
|
+
return () => listeners.delete(listener);
|
|
543
|
+
}
|
|
544
|
+
if (credential.protocol !== PAIR_VERSION) {
|
|
545
|
+
setState("outdated", outdatedError(`this device's pairing with ${credential.name}`));
|
|
546
|
+
queueMicrotask(() => {
|
|
547
|
+
if (state === "outdated" && lastError) notify({ state, error: lastError });
|
|
548
|
+
});
|
|
549
|
+
} else void run().catch((err) => {
|
|
550
|
+
setState("offline", new TunnelClientError("offline", errMsg(err), { cause: err }));
|
|
551
|
+
});
|
|
552
|
+
return {
|
|
553
|
+
credential,
|
|
554
|
+
get state() {
|
|
555
|
+
return state;
|
|
556
|
+
},
|
|
557
|
+
get hello() {
|
|
558
|
+
return hello;
|
|
559
|
+
},
|
|
560
|
+
get lastError() {
|
|
561
|
+
return lastError;
|
|
562
|
+
},
|
|
563
|
+
onStateChange: onStateChangeImpl,
|
|
564
|
+
ready() {
|
|
565
|
+
return waitForLive(new AbortController().signal).then(() => void 0);
|
|
566
|
+
},
|
|
567
|
+
fetch: tunnelFetch,
|
|
568
|
+
reconnect() {
|
|
569
|
+
wake?.();
|
|
570
|
+
},
|
|
571
|
+
close() {
|
|
572
|
+
if (stopped) return;
|
|
573
|
+
stopped = true;
|
|
574
|
+
wake?.();
|
|
575
|
+
const l = live;
|
|
576
|
+
live = null;
|
|
577
|
+
if (l) {
|
|
578
|
+
failPending(l, new TunnelClientError("closed", "the tunnel client was closed"));
|
|
579
|
+
l.sink.close("client closed");
|
|
580
|
+
}
|
|
581
|
+
if (state !== "revoked" && state !== "outdated") setState("closed");
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function makeResponse(status, headers, body) {
|
|
586
|
+
const h = new Headers();
|
|
587
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
588
|
+
if (DROP_RESPONSE_HEADERS.has(k.toLowerCase())) continue;
|
|
589
|
+
try {
|
|
590
|
+
h.append(k, v);
|
|
591
|
+
} catch {
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const s = Number.isInteger(status) && status >= 200 && status <= 599 ? status : 502;
|
|
595
|
+
if (NULL_BODY_STATUS.has(s)) {
|
|
596
|
+
if (body instanceof ReadableStream) void body.cancel().catch(() => {
|
|
597
|
+
});
|
|
598
|
+
return new Response(null, { status: s, headers: h });
|
|
599
|
+
}
|
|
600
|
+
return new Response(body, { status: s, headers: h });
|
|
601
|
+
}
|
|
602
|
+
async function parse(offerUrl, now) {
|
|
603
|
+
try {
|
|
604
|
+
return await parseOfferUrl(offerUrl, { now });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (err instanceof PairingError && err.code === "pairing_protocol_outdated") {
|
|
607
|
+
throw outdatedError("this pairing QR", { cause: err });
|
|
608
|
+
}
|
|
609
|
+
const expired = err instanceof PairingError && err.code === "offer_expired";
|
|
610
|
+
throw new TunnelClientError(
|
|
611
|
+
"invalid_offer",
|
|
612
|
+
expired ? "this pairing offer has expired; run `agentproto pair offer --qr` again" : errMsg(err),
|
|
613
|
+
{ cause: err }
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function inspectOffer(offerUrl, opts = {}) {
|
|
618
|
+
const offer = await parse(offerUrl, opts.now ?? Date.now());
|
|
619
|
+
return {
|
|
620
|
+
fingerprint: offer.fingerprint,
|
|
621
|
+
rendezvousUrl: offer.rendezvousUrl,
|
|
622
|
+
expiresAt: new Date(offer.exp * 1e3)
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function defaultClientName() {
|
|
626
|
+
const host = globalThis.location?.host;
|
|
627
|
+
return host ? `browser@${host}` : "browser";
|
|
628
|
+
}
|
|
629
|
+
async function pairFromOffer(offerUrl, opts = {}) {
|
|
630
|
+
const now = opts.now ?? Date.now;
|
|
631
|
+
const offer = await parse(offerUrl, now());
|
|
632
|
+
const clientName = opts.clientName ?? defaultClientName();
|
|
633
|
+
let channel;
|
|
634
|
+
try {
|
|
635
|
+
const { route, auth } = await deriveOfferTokens(offer.secret);
|
|
636
|
+
channel = await openChannel({
|
|
637
|
+
rendezvousUrl: offer.rendezvousUrl,
|
|
638
|
+
route,
|
|
639
|
+
auth,
|
|
640
|
+
daemonX25519Pub: offer.daemonX25519Pub,
|
|
641
|
+
daemonEd25519Pub: offer.daemonEd25519Pub,
|
|
642
|
+
clientName,
|
|
643
|
+
WebSocket: opts.WebSocket ?? defaultWebSocket(),
|
|
644
|
+
...opts.dialTimeoutMs !== void 0 ? { dialTimeoutMs: opts.dialTimeoutMs } : {},
|
|
645
|
+
...opts.handshakeTimeoutMs !== void 0 ? { handshakeTimeoutMs: opts.handshakeTimeoutMs } : {},
|
|
646
|
+
...opts.greetingTimeoutMs !== void 0 ? { greetingTimeoutMs: opts.greetingTimeoutMs } : {}
|
|
647
|
+
});
|
|
648
|
+
} catch (err) {
|
|
649
|
+
throw new TunnelClientError("pairing_failed", `could not pair with daemon ${offer.fingerprint}: ${errMsg(err)}`, {
|
|
650
|
+
cause: err
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const { sink, session, greeting } = channel;
|
|
654
|
+
if (session.peerFingerprint !== offer.fingerprint) {
|
|
655
|
+
sink.close("fingerprint mismatch");
|
|
656
|
+
throw new TunnelClientError(
|
|
657
|
+
"pairing_failed",
|
|
658
|
+
`daemon fingerprint ${session.peerFingerprint} does not match the offer's ${offer.fingerprint}`
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (greeting.kind === "outdated") {
|
|
662
|
+
sink.close("outdated");
|
|
663
|
+
throw outdatedError("this daemon");
|
|
664
|
+
}
|
|
665
|
+
let pairRoot = await derivePairRoot(session);
|
|
666
|
+
sink.close("pair complete");
|
|
667
|
+
const hello = greeting.kind === "hello" ? greeting.hello : null;
|
|
668
|
+
const daemon = {
|
|
669
|
+
fingerprint: offer.fingerprint,
|
|
670
|
+
name: daemonNameFromHello(hello, offer.fingerprint),
|
|
671
|
+
...hello?.label ? { label: hello.label } : {},
|
|
672
|
+
...hello?.daemon?.platform ? { platform: hello.daemon.platform } : {},
|
|
673
|
+
rendezvousUrl: offer.rendezvousUrl
|
|
674
|
+
};
|
|
675
|
+
return {
|
|
676
|
+
daemon,
|
|
677
|
+
async confirm() {
|
|
678
|
+
const root = pairRoot;
|
|
679
|
+
if (root === null) throw new TunnelClientError("cancelled", "this pairing was already confirmed or cancelled");
|
|
680
|
+
pairRoot = null;
|
|
681
|
+
const nowIso = new Date(now()).toISOString();
|
|
682
|
+
const credential = {
|
|
683
|
+
protocol: PAIR_VERSION,
|
|
684
|
+
id: offer.fingerprint,
|
|
685
|
+
fingerprint: offer.fingerprint,
|
|
686
|
+
name: daemon.name,
|
|
687
|
+
clientName,
|
|
688
|
+
daemonX25519Pub: offer.daemonX25519Pub,
|
|
689
|
+
daemonEd25519Pub: offer.daemonEd25519Pub,
|
|
690
|
+
rendezvousUrl: offer.rendezvousUrl,
|
|
691
|
+
pairRoot: await importPairRootKey(root),
|
|
692
|
+
createdAt: nowIso,
|
|
693
|
+
lastSeen: nowIso
|
|
694
|
+
};
|
|
695
|
+
await opts.store?.put(credential);
|
|
696
|
+
return credential;
|
|
697
|
+
},
|
|
698
|
+
cancel() {
|
|
699
|
+
pairRoot = null;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/credential.ts
|
|
705
|
+
function createMemoryCredentialStore(initial = []) {
|
|
706
|
+
const byId = new Map(initial.map((c) => [c.id, c]));
|
|
707
|
+
return {
|
|
708
|
+
async get(id) {
|
|
709
|
+
return byId.get(id);
|
|
710
|
+
},
|
|
711
|
+
async put(credential) {
|
|
712
|
+
byId.set(credential.id, credential);
|
|
713
|
+
},
|
|
714
|
+
async delete(id) {
|
|
715
|
+
return byId.delete(id);
|
|
716
|
+
},
|
|
717
|
+
async list() {
|
|
718
|
+
return [...byId.values()];
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
function createIndexedDbCredentialStore(opts = {}) {
|
|
723
|
+
const dbName = opts.dbName ?? "agentproto-pair";
|
|
724
|
+
const storeName = opts.storeName ?? "credentials";
|
|
725
|
+
let dbPromise = null;
|
|
726
|
+
const open = () => {
|
|
727
|
+
if (dbPromise) return dbPromise;
|
|
728
|
+
const factory = opts.indexedDB ?? globalThis.indexedDB;
|
|
729
|
+
if (!factory) return Promise.reject(new Error("IndexedDB is not available in this context"));
|
|
730
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
731
|
+
const req = factory.open(dbName, 1);
|
|
732
|
+
req.onupgradeneeded = () => {
|
|
733
|
+
if (!req.result.objectStoreNames.contains(storeName)) {
|
|
734
|
+
req.result.createObjectStore(storeName, { keyPath: "id" });
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
req.onsuccess = () => resolve(req.result);
|
|
738
|
+
req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed"));
|
|
739
|
+
});
|
|
740
|
+
dbPromise.catch(() => {
|
|
741
|
+
dbPromise = null;
|
|
742
|
+
});
|
|
743
|
+
return dbPromise;
|
|
744
|
+
};
|
|
745
|
+
const run = async (mode, op) => {
|
|
746
|
+
const db = await open();
|
|
747
|
+
return new Promise((resolve, reject) => {
|
|
748
|
+
const tx = db.transaction(storeName, mode);
|
|
749
|
+
const req = op(tx.objectStore(storeName));
|
|
750
|
+
tx.oncomplete = () => resolve(req.result);
|
|
751
|
+
tx.onerror = () => reject(tx.error ?? req.error ?? new Error("IndexedDB request failed"));
|
|
752
|
+
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
753
|
+
});
|
|
754
|
+
};
|
|
755
|
+
return {
|
|
756
|
+
async get(id) {
|
|
757
|
+
return await run("readonly", (s) => s.get(id)) ?? void 0;
|
|
758
|
+
},
|
|
759
|
+
async put(credential) {
|
|
760
|
+
await run("readwrite", (s) => s.put(credential));
|
|
761
|
+
},
|
|
762
|
+
async delete(id) {
|
|
763
|
+
const existing = await run("readonly", (s) => s.count(id));
|
|
764
|
+
if (existing === 0) return false;
|
|
765
|
+
await run("readwrite", (s) => s.delete(id));
|
|
766
|
+
return true;
|
|
767
|
+
},
|
|
768
|
+
async list() {
|
|
769
|
+
return run("readonly", (s) => s.getAll());
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export { PAIRING_REVOKED_CODE, TunnelClientError, connect, createIndexedDbCredentialStore, createMemoryCredentialStore, inspectOffer, pairFromOffer };
|
|
775
|
+
//# sourceMappingURL=index.mjs.map
|
|
776
|
+
//# sourceMappingURL=index.mjs.map
|