agent-yes 1.120.0 → 1.121.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/ts/serve.ts CHANGED
@@ -313,8 +313,9 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
313
313
  process.stdout.write(` ay serve uninstall # remove daemon\n`);
314
314
  if (webrtcish) {
315
315
  process.stdout.write(
316
- `\nthe WebRTC share link is printed by the daemon see: ay serve logs\n` +
317
- `(the room persists in ~/.agent-yes/.share-room, so the link survives restarts)\n`,
316
+ `\nthe WebRTC share link carries a secret, so the daemon does NOT log it —\n` +
317
+ `read it from ~/.agent-yes/.share-link (mode 0600). The room persists in\n` +
318
+ `~/.agent-yes/.share-room, so the link survives restarts.\n`,
318
319
  );
319
320
  }
320
321
  }
@@ -1002,6 +1003,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
1002
1003
  return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
1003
1004
  if (req.method === "GET" && p === "/console-logic.js")
1004
1005
  return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
1006
+ if (req.method === "GET" && p === "/e2e.js")
1007
+ return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
1005
1008
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
1006
1009
  return apiFetch(req);
1007
1010
  };
@@ -1063,18 +1066,37 @@ export async function cmdServe(rest: string[]): Promise<number> {
1063
1066
  const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
1064
1067
  // No explicit webrtc:// URL → reuse the persisted room (minted once and
1065
1068
  // saved like the serve token), so the link is stable across restarts.
1066
- const { link, close } = await startShare({
1069
+ const { room, link, close } = await startShare({
1067
1070
  url: explicitUrl ?? (await loadOrCreateShareRoom()),
1068
1071
  localFetch: apiFetch,
1069
1072
  apiToken: token,
1070
1073
  });
1071
1074
  closeShare = close;
1072
- process.stdout.write(
1073
- `${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
1074
- (explicitUrl
1075
- ? "\n"
1076
- : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`),
1077
- );
1075
+ const persistNote = explicitUrl
1076
+ ? "\n"
1077
+ : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
1078
+ if (process.stdout.isTTY) {
1079
+ process.stdout.write(
1080
+ `${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
1081
+ persistNote,
1082
+ );
1083
+ } else {
1084
+ // Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
1085
+ // write it to a log stream. Stash it in a 0600 file and point there instead.
1086
+ const linkFile = path.join(
1087
+ process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"),
1088
+ ".share-link",
1089
+ );
1090
+ try {
1091
+ await writeFile(linkFile, link + "\n", { mode: 0o600 });
1092
+ } catch {
1093
+ /* best effort */
1094
+ }
1095
+ process.stdout.write(
1096
+ `${wantHttp ? "\n" : ""}shared over WebRTC · room ${room} — the link carries a secret, so it is NOT logged.\n` +
1097
+ ` read it from ${linkFile} (mode 0600); delete ~/.agent-yes/.share-room to rotate\n\n`,
1098
+ );
1099
+ }
1078
1100
  } catch (e) {
1079
1101
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
1080
1102
  if (!wantHttp) return 1; // nothing else is running
package/ts/share.ts CHANGED
@@ -8,10 +8,24 @@ import { randomBytes } from "crypto";
8
8
  import { mkdir, readFile, writeFile } from "fs/promises";
9
9
  import { homedir } from "os";
10
10
  import path from "path";
11
+ import {
12
+ CONFIRM_TIMEOUT_MS,
13
+ FLAG_CONFIRM,
14
+ MARKER,
15
+ MAX_CHUNK,
16
+ computeTranscriptHash,
17
+ deriveAuthToken,
18
+ deriveDirKeys,
19
+ open as e2eOpen,
20
+ seal as e2eSeal,
21
+ packEnvelope,
22
+ parseSecret,
23
+ randomHex,
24
+ unpackEnvelope,
25
+ } from "../lab/ui/e2e.js";
11
26
 
12
27
  const SUB = "ay-signal-1";
13
28
  const ICE = [{ urls: "stun:stun.l.google.com:19302" }];
14
- const MAX_CHUNK = 15_000; // keep DataChannel messages under the SCTP limit
15
29
  const DEFAULT_SIGHOST = "s.agent-yes.com";
16
30
 
17
31
  export interface ShareOpts {
@@ -38,13 +52,20 @@ function shareRoomPath(): string {
38
52
  export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<string> {
39
53
  try {
40
54
  const url = (await readFile(shareRoomPath(), "utf-8")).trim();
41
- if (url.startsWith("webrtc://")) return url;
55
+ if (url.startsWith("webrtc://")) {
56
+ // A v2 (encrypted) room carries the e1. marker on its secret — reuse it.
57
+ // A legacy markerless room is rotated to a fresh encrypted room below: the
58
+ // signaling DO has pinned the old room to its plaintext token, so we must
59
+ // mint a NEW room name. This is a one-time, deliberate security upgrade —
60
+ // old share links stop working; re-open the new printed link.
61
+ if (parseShareUrl(url).token.startsWith(MARKER)) return url;
62
+ }
42
63
  } catch {
43
64
  /* not yet minted */
44
65
  }
45
66
  const room = "r" + randomBytes(3).toString("hex");
46
- const token = randomBytes(32).toString("hex");
47
- const url = `webrtc://${room}:${token}@${sighost}`;
67
+ const s = randomBytes(32).toString("hex");
68
+ const url = `webrtc://${room}:${MARKER}${s}@${sighost}`;
48
69
  await mkdir(path.dirname(shareRoomPath()), { recursive: true });
49
70
  await writeFile(shareRoomPath(), url, { mode: 0o600 });
50
71
  return url;
@@ -146,17 +167,46 @@ export async function startShare(
146
167
  ? parseShareUrl(opts.url)
147
168
  : {
148
169
  room: "r" + randomBytes(3).toString("hex"),
149
- token: randomBytes(32).toString("hex"),
170
+ token: `${MARKER}${randomBytes(32).toString("hex")}`,
150
171
  host: sighost,
151
172
  };
152
173
 
174
+ // E2E: the URL secret S splits into authToken (the only value the server sees,
175
+ // for room matching) and per-connection AES keys the server never sees. We
176
+ // refuse to host a legacy plaintext room — old rooms are auto-rotated to v2 by
177
+ // loadOrCreateShareRoom (delete ~/.agent-yes/.share-room to force a rotation).
178
+ const { s: S, v2 } = parseSecret(token);
179
+ if (!v2) {
180
+ throw new Error(
181
+ "refusing to host an unencrypted room — delete ~/.agent-yes/.share-room to rotate to an encrypted link",
182
+ );
183
+ }
184
+ const authToken = await deriveAuthToken(S, room, host);
185
+
153
186
  const RTCPeerConnection = await importRTC();
154
187
  const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
155
188
  const ui = host === "s.agent-yes.com" ? "https://agent-yes.com" : "http://localhost:7778";
156
189
  const suffix = host === "s.agent-yes.com" ? "" : "@" + host;
157
- const link = `${ui}/#${room}:${token}${suffix}`;
190
+ const link = `${ui}/#${room}:${MARKER}${S}${suffix}`;
158
191
 
159
- type Peer = { pc: any; aborts: Map<number, AbortController> };
192
+ type Peer = {
193
+ pc: any;
194
+ aborts: Map<string, AbortController>;
195
+ send: { sendCtr: bigint };
196
+ recv: { lastSeen: bigint };
197
+ th?: Uint8Array;
198
+ keyH2C?: CryptoKey; // host encrypts with H2C, decrypts with C2H
199
+ keyC2H?: CryptoKey;
200
+ keysReady: Promise<void>;
201
+ resolveKeys: () => void;
202
+ myNonce: string;
203
+ confirmedIn: boolean; // peer echoed our nonce
204
+ confirmedOut: boolean; // we echoed peer's nonce
205
+ confirmed: boolean;
206
+ confirmTimer?: ReturnType<typeof setTimeout>;
207
+ recvChain: Promise<void>; // serialize decrypts so the replay counter stays ordered
208
+ sendChain: Promise<void>; // serialize seals so wire order == counter order
209
+ };
160
210
  const peers = new Map<string, Peer>();
161
211
  let closed = false; // set by close(); stops signaling reconnect + new peers
162
212
  let currentWs: WebSocket | undefined; // the live rendezvous socket, for close()
@@ -167,7 +217,7 @@ export async function startShare(
167
217
  currentWs = ws;
168
218
  let ready = false;
169
219
  ws.onopen = () => {
170
- ws.send(JSON.stringify({ type: "hello", role: "host", token }));
220
+ ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
171
221
  ready = true;
172
222
  onReady();
173
223
  };
@@ -175,17 +225,44 @@ export async function startShare(
175
225
  if (closed) return;
176
226
  const m = JSON.parse(ev.data as string);
177
227
  if (m.type === "peer-join") startPeer(ws, m.peer);
178
- else if (m.type === "answer")
179
- await peers.get(m.from)?.pc.setRemoteDescription({ type: "answer", sdp: m.sdp });
180
- else if (m.type === "candidate")
228
+ else if (m.type === "answer") {
229
+ const peer = peers.get(m.from);
230
+ if (!peer) return;
231
+ try {
232
+ await peer.pc.setRemoteDescription({ type: "answer", sdp: m.sdp });
233
+ // Derive per-connection keys the moment both descriptions are stable —
234
+ // before the DataChannel can open and deliver a frame. Host's offer is
235
+ // local, the browser's answer is remote.
236
+ peer.th = await computeTranscriptHash(
237
+ peer.pc.localDescription.sdp,
238
+ peer.pc.remoteDescription.sdp,
239
+ );
240
+ const { keyH2C, keyC2H } = await deriveDirKeys(S, peer.th);
241
+ peer.keyH2C = keyH2C;
242
+ peer.keyC2H = keyC2H;
243
+ peer.resolveKeys();
244
+ } catch {
245
+ closePeer(m.from);
246
+ }
247
+ } else if (m.type === "candidate")
181
248
  await peers
182
249
  .get(m.from)
183
250
  ?.pc.addIceCandidate(m.candidate)
184
251
  .catch(() => {});
185
252
  else if (m.type === "peer-leave") closePeer(m.peer);
186
253
  };
187
- ws.onclose = () => {
254
+ ws.onclose = (ev: any) => {
188
255
  if (closed) return; // shutting down — don't resurrect the rendezvous
256
+ // The signaling server pins a room to its first host's authToken. A 1008
257
+ // means a different generation already owns this room — don't hot-loop;
258
+ // tell the operator to rotate. (Secret-free message.)
259
+ if (ev?.code === 1008) {
260
+ closed = true;
261
+ process.stderr.write(
262
+ "[share] room rejected by signaling server — delete ~/.agent-yes/.share-room to rotate the room\n",
263
+ );
264
+ return;
265
+ }
189
266
  // Keep established WebRTC peers; just re-establish the rendezvous so new
190
267
  // browsers can still join. Backoff a little to avoid hot-looping.
191
268
  setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4000);
@@ -196,8 +273,23 @@ export async function startShare(
196
273
 
197
274
  function startPeer(ws: WebSocket, peerId: string) {
198
275
  const pc = new RTCPeerConnection({ iceServers: ICE });
199
- const aborts = new Map<number, AbortController>();
200
- peers.set(peerId, { pc, aborts });
276
+ let resolveKeys!: () => void;
277
+ const keysReady = new Promise<void>((r) => (resolveKeys = r));
278
+ const peer: Peer = {
279
+ pc,
280
+ aborts: new Map<string, AbortController>(),
281
+ send: { sendCtr: 0n },
282
+ recv: { lastSeen: -1n },
283
+ keysReady,
284
+ resolveKeys,
285
+ myNonce: randomHex(16),
286
+ confirmedIn: false,
287
+ confirmedOut: false,
288
+ confirmed: false,
289
+ recvChain: Promise.resolve(),
290
+ sendChain: Promise.resolve(),
291
+ };
292
+ peers.set(peerId, peer);
201
293
  pc.onicecandidate = (e: any) => {
202
294
  if (e.candidate)
203
295
  ws.send(JSON.stringify({ type: "candidate", to: peerId, candidate: e.candidate }));
@@ -206,7 +298,26 @@ export async function startShare(
206
298
  if (["failed", "closed", "disconnected"].includes(pc.connectionState)) closePeer(peerId);
207
299
  };
208
300
  const dc = pc.createDataChannel("api");
209
- dc.onmessage = (e: any) => onReq(dc, aborts, JSON.parse(e.data));
301
+ dc.binaryType = "arraybuffer";
302
+ dc.onopen = async () => {
303
+ try {
304
+ await peer.keysReady; // keys derived in the answer handler
305
+ // Open the mandatory bidirectional key-confirmation handshake. Nothing
306
+ // the peer sends is acted on until BOTH directions confirm (see onFrame).
307
+ enqueueSeal(peerId, dc, peer, FLAG_CONFIRM, { t: "confirm", nonce: peer.myNonce });
308
+ peer.confirmTimer = setTimeout(() => {
309
+ if (!peer.confirmed) closePeer(peerId);
310
+ }, CONFIRM_TIMEOUT_MS);
311
+ } catch {
312
+ closePeer(peerId);
313
+ }
314
+ };
315
+ // Serialize decrypts: WebCrypto open() is async, and a reliable+ordered
316
+ // channel must be processed in order or the monotonic replay check would
317
+ // spuriously reject a reordered await.
318
+ dc.onmessage = (e: any) => {
319
+ peer.recvChain = peer.recvChain.then(() => onFrame(peerId, dc, peer, e.data)).catch(() => {});
320
+ };
210
321
  pc.createOffer()
211
322
  .then((o: any) => pc.setLocalDescription(o))
212
323
  .then(() =>
@@ -217,6 +328,7 @@ export async function startShare(
217
328
  function closePeer(peerId: string) {
218
329
  const p = peers.get(peerId);
219
330
  if (!p) return;
331
+ if (p.confirmTimer) clearTimeout(p.confirmTimer);
220
332
  for (const a of p.aborts.values()) a.abort();
221
333
  try {
222
334
  p.pc.close();
@@ -226,29 +338,72 @@ export async function startShare(
226
338
  peers.delete(peerId);
227
339
  }
228
340
 
229
- function send(dc: any, obj: object) {
230
- // readyState alone is racy: node-datachannel can still report "open" for a
231
- // tick after a dropped peer's channel is torn down underneath, so dc.send()
232
- // throws "DataChannel is closed". Swallow it — the frame is for a peer that's
233
- // already gone (closePeer aborts its in-flight requests right behind this).
234
- if (dc.readyState !== "open") return;
341
+ // Seal an envelope and send it, serialized per peer so the wire order matches
342
+ // the nonce-counter order (so the receiver's monotonic check never trips).
343
+ function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
344
+ peer.sendChain = peer.sendChain.then(async () => {
345
+ if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
346
+ let frame: ArrayBuffer;
347
+ try {
348
+ frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
349
+ } catch {
350
+ closePeer(peerId); // counter overflow — fail closed
351
+ return;
352
+ }
353
+ try {
354
+ dc.send(frame);
355
+ } catch {
356
+ /* peer vanished mid-send; dropping the frame is correct */
357
+ }
358
+ });
359
+ return peer.sendChain;
360
+ }
361
+
362
+ // Decrypt + route one inbound frame. Fail-closed: any decryption failure,
363
+ // replay, pre-confirmation app frame, or string frame closes the peer.
364
+ async function onFrame(peerId: string, dc: any, peer: Peer, data: any) {
365
+ if (!peers.has(peerId)) return;
366
+ if (typeof data === "string" || !peer.keyC2H || !peer.th) return closePeer(peerId);
367
+ let env: any;
235
368
  try {
236
- dc.send(JSON.stringify(obj));
369
+ const { plaintext } = await e2eOpen(peer.keyC2H, data, peer.th, peer.recv);
370
+ env = unpackEnvelope(plaintext);
237
371
  } catch {
238
- /* peer vanished mid-send; dropping the frame is correct */
372
+ return closePeer(peerId); // bad version/epoch/tag/AAD or replay
373
+ }
374
+ if (!peer.confirmed) {
375
+ if (!env || env.t !== "confirm") return closePeer(peerId);
376
+ if (typeof env.nonce === "string" && !peer.confirmedOut) {
377
+ // Flush our echo before marking confirmed-out (so no app frame is acted on
378
+ // until the peer can also complete its side).
379
+ await enqueueSeal(peerId, dc, peer, FLAG_CONFIRM, {
380
+ t: "confirm",
381
+ nonce: peer.myNonce,
382
+ echo: env.nonce,
383
+ });
384
+ peer.confirmedOut = true;
385
+ }
386
+ if (env.echo && env.echo === peer.myNonce) peer.confirmedIn = true;
387
+ if (peer.confirmedIn && peer.confirmedOut) {
388
+ peer.confirmed = true;
389
+ if (peer.confirmTimer) clearTimeout(peer.confirmTimer);
390
+ }
391
+ return;
239
392
  }
393
+ if (!env || env.t === "confirm") return; // stray confirm after handshake — ignore
394
+ onReq(peerId, dc, peer, env);
240
395
  }
241
396
 
242
- async function onReq(dc: any, aborts: Map<number, AbortController>, req: any) {
397
+ async function onReq(peerId: string, dc: any, peer: Peer, req: any) {
243
398
  if (req.t === "abort") {
244
- aborts.get(req.id)?.abort();
245
- aborts.delete(req.id);
399
+ peer.aborts.get(req.id)?.abort();
400
+ peer.aborts.delete(req.id);
246
401
  return;
247
402
  }
248
403
  if (req.t !== "req") return;
249
404
  const { id, method, path: p, body } = req;
250
405
  const ac = new AbortController();
251
- aborts.set(id, ac);
406
+ peer.aborts.set(id, ac);
252
407
  try {
253
408
  // The host part is a placeholder — the handler only routes on the path.
254
409
  const res = await opts.localFetch(
@@ -262,21 +417,39 @@ export async function startShare(
262
417
  signal: ac.signal,
263
418
  }),
264
419
  );
265
- send(dc, { t: "res", id, status: res.status, ct: res.headers.get("content-type") ?? "" });
420
+ enqueueSeal(peerId, dc, peer, 0, {
421
+ t: "res",
422
+ id,
423
+ status: res.status,
424
+ ct: res.headers.get("content-type") ?? "",
425
+ });
266
426
  const reader = res.body!.getReader();
267
427
  const dec = new TextDecoder();
428
+ let seq = 0;
268
429
  for (;;) {
269
430
  const { done, value } = await reader.read();
270
431
  if (done) break;
271
432
  const text = dec.decode(value, { stream: true });
433
+ // Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
434
+ // so the receiver reassembles the exact text by concatenating in seq order.
272
435
  for (let i = 0; i < text.length; i += MAX_CHUNK)
273
- send(dc, { t: "data", id, chunk: text.slice(i, i + MAX_CHUNK) });
436
+ enqueueSeal(peerId, dc, peer, 0, {
437
+ t: "data",
438
+ id,
439
+ seq: seq++,
440
+ chunk: text.slice(i, i + MAX_CHUNK),
441
+ });
274
442
  }
275
- send(dc, { t: "end", id });
443
+ enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
276
444
  } catch (e) {
277
- if ((e as Error).name !== "AbortError") send(dc, { t: "end", id, error: String(e) });
445
+ if ((e as Error).name !== "AbortError")
446
+ enqueueSeal(peerId, dc, peer, 0, {
447
+ t: "end",
448
+ id,
449
+ error: String((e as Error).message ?? e),
450
+ });
278
451
  } finally {
279
- aborts.delete(id);
452
+ peer.aborts.delete(id);
280
453
  }
281
454
  }
282
455
 
@@ -1,8 +0,0 @@
1
- import "./ts-C78N0K4F.js";
2
- import "./logger-B9h0djqx.js";
3
- import "./versionChecker-CYZtJKMG.js";
4
- import "./pidStore-B5vBu8Px.js";
5
- import "./globalPidIndex-gZuTvTBs.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BD8zWc7O.js";
7
-
8
- export { SUPPORTED_CLIS };
@@ -1,254 +0,0 @@
1
- import { mkdir, readFile, writeFile } from "fs/promises";
2
- import { homedir } from "os";
3
- import path from "path";
4
- import { randomBytes } from "crypto";
5
-
6
- //#region ts/share.ts
7
- const SUB = "ay-signal-1";
8
- const ICE = [{ urls: "stun:stun.l.google.com:19302" }];
9
- const MAX_CHUNK = 15e3;
10
- const DEFAULT_SIGHOST = "s.agent-yes.com";
11
- function shareRoomPath() {
12
- const home = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
13
- return path.join(home, ".share-room");
14
- }
15
- async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST) {
16
- try {
17
- const url = (await readFile(shareRoomPath(), "utf-8")).trim();
18
- if (url.startsWith("webrtc://")) return url;
19
- } catch {}
20
- const url = `webrtc://${"r" + randomBytes(3).toString("hex")}:${randomBytes(32).toString("hex")}@${sighost}`;
21
- await mkdir(path.dirname(shareRoomPath()), { recursive: true });
22
- await writeFile(shareRoomPath(), url, { mode: 384 });
23
- return url;
24
- }
25
- function parseShareUrl(s) {
26
- const m = /^webrtc:\/\/([^:@/]+):([^@/]+)@(.+)$/.exec(s);
27
- if (!m) throw new Error(`bad --share url: ${s} (want webrtc://room:token@host)`);
28
- return {
29
- room: m[1],
30
- token: m[2],
31
- host: m[3]
32
- };
33
- }
34
- async function linkFromBunCache() {
35
- const { existsSync, symlinkSync, mkdirSync, readdirSync } = await import("fs");
36
- const path = (await import("path")).default;
37
- const { createRequire } = await import("module");
38
- const require = createRequire(import.meta.url);
39
- const pkg = path.dirname(require.resolve("node-datachannel/package.json"));
40
- const bin = path.join(pkg, "build", "Release", "node_datachannel.node");
41
- const cacheRoot = path.join((await import("os")).homedir(), ".bun", "install", "cache");
42
- if (existsSync(bin) && existsSync(cacheRoot)) for (const d of readdirSync(cacheRoot)) {
43
- if (!d.startsWith("node-datachannel@")) continue;
44
- const dst = path.join(cacheRoot, d, "build", "Release");
45
- mkdirSync(dst, { recursive: true });
46
- const link = path.join(dst, "node_datachannel.node");
47
- if (!existsSync(link)) symlinkSync(bin, link);
48
- }
49
- }
50
- async function ndPackageDir() {
51
- try {
52
- const path = (await import("path")).default;
53
- const { createRequire } = await import("module");
54
- const require = createRequire(import.meta.url);
55
- return path.dirname(require.resolve("node-datachannel/package.json"));
56
- } catch {
57
- return null;
58
- }
59
- }
60
- async function ensureAddon(ndDir) {
61
- const { existsSync } = await import("fs");
62
- const path = (await import("path")).default;
63
- if (existsSync(path.join(ndDir, "build", "Release", "node_datachannel.node"))) return;
64
- try {
65
- const { createRequire } = await import("module");
66
- const binJs = createRequire(import.meta.url).resolve("prebuild-install/bin.js", { paths: [ndDir] });
67
- const { spawnSync } = await import("child_process");
68
- process.stderr.write("fetching node-datachannel prebuilt binary (one-time)…\n");
69
- spawnSync(process.execPath, [
70
- binJs,
71
- "-r",
72
- "napi"
73
- ], {
74
- cwd: ndDir,
75
- stdio: "ignore"
76
- });
77
- } catch {}
78
- }
79
- async function importRTC() {
80
- const ndDir = await ndPackageDir();
81
- if (ndDir) await ensureAddon(ndDir);
82
- try {
83
- return (await import("node-datachannel/polyfill")).RTCPeerConnection;
84
- } catch (firstErr) {
85
- await linkFromBunCache().catch(() => {});
86
- try {
87
- return (await import("node-datachannel/polyfill")).RTCPeerConnection;
88
- } catch {
89
- throw firstErr;
90
- }
91
- }
92
- }
93
- /** Start the share bridge. Resolves once signaling is connected; runs until the
94
- * process exits, reconnecting signaling on drop. Returns the shareable link. */
95
- async function startShare(opts) {
96
- opts.url;
97
- const sighost = opts.sighost ?? DEFAULT_SIGHOST;
98
- const { room, token, host } = opts.url ? parseShareUrl(opts.url) : {
99
- room: "r" + randomBytes(3).toString("hex"),
100
- token: randomBytes(32).toString("hex"),
101
- host: sighost
102
- };
103
- const RTCPeerConnection = await importRTC();
104
- const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
105
- const link = `${host === "s.agent-yes.com" ? "https://agent-yes.com" : "http://localhost:7778"}/#${room}:${token}${host === "s.agent-yes.com" ? "" : "@" + host}`;
106
- const peers = /* @__PURE__ */ new Map();
107
- let closed = false;
108
- let currentWs;
109
- const connectSignaling = (onReady) => {
110
- if (closed) return;
111
- const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
112
- currentWs = ws;
113
- let ready = false;
114
- ws.onopen = () => {
115
- ws.send(JSON.stringify({
116
- type: "hello",
117
- role: "host",
118
- token
119
- }));
120
- ready = true;
121
- onReady();
122
- };
123
- ws.onmessage = async (ev) => {
124
- if (closed) return;
125
- const m = JSON.parse(ev.data);
126
- if (m.type === "peer-join") startPeer(ws, m.peer);
127
- else if (m.type === "answer") await peers.get(m.from)?.pc.setRemoteDescription({
128
- type: "answer",
129
- sdp: m.sdp
130
- });
131
- else if (m.type === "candidate") await peers.get(m.from)?.pc.addIceCandidate(m.candidate).catch(() => {});
132
- else if (m.type === "peer-leave") closePeer(m.peer);
133
- };
134
- ws.onclose = () => {
135
- if (closed) return;
136
- setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4e3);
137
- };
138
- ws.onerror = () => {};
139
- return ws;
140
- };
141
- function startPeer(ws, peerId) {
142
- const pc = new RTCPeerConnection({ iceServers: ICE });
143
- const aborts = /* @__PURE__ */ new Map();
144
- peers.set(peerId, {
145
- pc,
146
- aborts
147
- });
148
- pc.onicecandidate = (e) => {
149
- if (e.candidate) ws.send(JSON.stringify({
150
- type: "candidate",
151
- to: peerId,
152
- candidate: e.candidate
153
- }));
154
- };
155
- pc.onconnectionstatechange = () => {
156
- if ([
157
- "failed",
158
- "closed",
159
- "disconnected"
160
- ].includes(pc.connectionState)) closePeer(peerId);
161
- };
162
- const dc = pc.createDataChannel("api");
163
- dc.onmessage = (e) => onReq(dc, aborts, JSON.parse(e.data));
164
- pc.createOffer().then((o) => pc.setLocalDescription(o)).then(() => ws.send(JSON.stringify({
165
- type: "offer",
166
- to: peerId,
167
- sdp: pc.localDescription.sdp
168
- })));
169
- }
170
- function closePeer(peerId) {
171
- const p = peers.get(peerId);
172
- if (!p) return;
173
- for (const a of p.aborts.values()) a.abort();
174
- try {
175
- p.pc.close();
176
- } catch {}
177
- peers.delete(peerId);
178
- }
179
- function send(dc, obj) {
180
- if (dc.readyState !== "open") return;
181
- try {
182
- dc.send(JSON.stringify(obj));
183
- } catch {}
184
- }
185
- async function onReq(dc, aborts, req) {
186
- if (req.t === "abort") {
187
- aborts.get(req.id)?.abort();
188
- aborts.delete(req.id);
189
- return;
190
- }
191
- if (req.t !== "req") return;
192
- const { id, method, path: p, body } = req;
193
- const ac = new AbortController();
194
- aborts.set(id, ac);
195
- try {
196
- const res = await opts.localFetch(new Request(`http://ay.local${p}`, {
197
- method,
198
- headers: {
199
- Authorization: `Bearer ${opts.apiToken}`,
200
- ...body ? { "Content-Type": "application/json" } : {}
201
- },
202
- body: body ?? void 0,
203
- signal: ac.signal
204
- }));
205
- send(dc, {
206
- t: "res",
207
- id,
208
- status: res.status,
209
- ct: res.headers.get("content-type") ?? ""
210
- });
211
- const reader = res.body.getReader();
212
- const dec = new TextDecoder();
213
- for (;;) {
214
- const { done, value } = await reader.read();
215
- if (done) break;
216
- const text = dec.decode(value, { stream: true });
217
- for (let i = 0; i < text.length; i += MAX_CHUNK) send(dc, {
218
- t: "data",
219
- id,
220
- chunk: text.slice(i, i + MAX_CHUNK)
221
- });
222
- }
223
- send(dc, {
224
- t: "end",
225
- id
226
- });
227
- } catch (e) {
228
- if (e.name !== "AbortError") send(dc, {
229
- t: "end",
230
- id,
231
- error: String(e)
232
- });
233
- } finally {
234
- aborts.delete(id);
235
- }
236
- }
237
- await new Promise((resolve) => connectSignaling(resolve));
238
- const close = () => {
239
- closed = true;
240
- try {
241
- currentWs?.close();
242
- } catch {}
243
- for (const peerId of [...peers.keys()]) closePeer(peerId);
244
- };
245
- return {
246
- room,
247
- link,
248
- close
249
- };
250
- }
251
-
252
- //#endregion
253
- export { loadOrCreateShareRoom, startShare };
254
- //# sourceMappingURL=share-B7J79Wq9.js.map