agent-yes 1.302.2 → 1.303.0-beta.783.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{SUPPORTED_CLIS-FBshN0v2.js → SUPPORTED_CLIS-C8rpcE-P.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-C5Uhdl61.js → SUPPORTED_CLIS-ukR2iojt.js} +3 -3
  3. package/dist/{agentShare-CNgCQMY9.js → agentShare-BPz-DWhb.js} +4 -4
  4. package/dist/{callback-ayc9yXqJ.js → callback-CQOc7bl3.js} +3 -3
  5. package/dist/{callback-CbZ-H_Pn.js → callback-FvcCaQZP.js} +4 -4
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-CDj-l5Pk.js → cmdPs-oZEpAa9v.js} +4 -4
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-DZbd5fTj.js → notifyDaemon-Dg4SZBXh.js} +4 -4
  10. package/dist/{parentPingLoop-Bim6u0OH.js → parentPingLoop-BjDipYVr.js} +2 -2
  11. package/dist/{remotes-ByjpYjQ9.js → remotes-C36aI8Ty.js} +2 -2
  12. package/dist/{remotes-D10E_xUn.js → remotes-COBEhmlr.js} +6 -6
  13. package/dist/{rustBinary-Btf18sk5.js → rustBinary-0phuQaIs.js} +24 -3
  14. package/dist/rustBinary-CZptL6HA.js +4 -0
  15. package/dist/{schedule-DzqTosvJ.js → schedule-CuCWCw4g.js} +4 -4
  16. package/dist/{serve-DdHbUWtG.js → serve-CQqPMRbO.js} +44 -27
  17. package/dist/{serveRust-BEzTqFRy.js → serveRust-BgY5Te5E.js} +50 -6
  18. package/dist/{setup-CzKkVYp8.js → setup-Dt7dBtlK.js} +2 -2
  19. package/dist/{shareCmd-DinezAHY.js → shareCmd-Dvtm6NbI.js} +7 -7
  20. package/dist/{subcommands-oIARj_mJ.js → subcommands-CCdEFHc_.js} +3 -3
  21. package/dist/{subcommands-fX4Ygx3a.js → subcommands-CGQhH5RX.js} +30 -27
  22. package/dist/{terminal-DlW0KNym.js → terminal-9q3wQawj.js} +2 -2
  23. package/dist/{trayApp-DWVdr0Pr.js → trayApp-BKDrpclL.js} +2 -2
  24. package/dist/trayApp-Du2MFg7l.js +5 -0
  25. package/dist/{ts-D29CMeWZ.js → ts-DfSvex_p.js} +3 -3
  26. package/dist/{versionChecker-DrjMn-XI.js → versionChecker-CLrLOjMI.js} +2 -2
  27. package/dist/webrtcLink-CHLgPKWj.js +4 -0
  28. package/dist/{webrtcLink-CKnjNGaV.js → webrtcLink-bWxiCBTV.js} +26 -9
  29. package/dist/webrtcRemote-RmkbzY6Y.js +683 -0
  30. package/dist/{widget-CvcvPWF9.js → widget-CKSKxvxy.js} +3 -3
  31. package/dist/{ws-DDB8jh0f.js → ws-BKJ17qZk.js} +4 -4
  32. package/dist/{ws-Cbx5sfsd.js → ws-DMx_Yw4Z.js} +2 -2
  33. package/lab/ui/index.html +71 -3
  34. package/package.json +1 -1
  35. package/ts/remotes.ts +11 -4
  36. package/ts/rustBinary.ts +19 -0
  37. package/ts/serve.ts +26 -4
  38. package/ts/serveRust.spec.ts +23 -6
  39. package/ts/serveRust.ts +34 -7
  40. package/ts/subcommands.ts +4 -1
  41. package/ts/webrtcLink.spec.ts +26 -1
  42. package/ts/webrtcLink.ts +32 -5
  43. package/ts/webrtcRemote.ts +406 -102
  44. package/ts/webrtcSignal.spec.ts +40 -0
  45. package/ts/webrtcSignal.ts +59 -3
  46. package/dist/rustBinary-DklBSefL.js +0 -4
  47. package/dist/trayApp-F_Xs3C2N.js +0 -5
  48. package/dist/webrtcLink-CAdvnRM0.js +0 -4
  49. package/dist/webrtcRemote-BYy2lTTl.js +0 -408
@@ -8,6 +8,7 @@
8
8
  // responds; we answer and request. Crypto is reused verbatim from lab/ui/e2e.js
9
9
  // so the AES-GCM / transcript-hash handshake is byte-identical to the browser
10
10
  // console and the host.
11
+ import { hostname } from "os";
11
12
  import { RTCPeerConnection } from "node-datachannel/polyfill";
12
13
  import { probeSignalingHost, signalingFailureHint } from "./signalingHint.ts";
13
14
  import {
@@ -23,11 +24,17 @@ import {
23
24
  } from "../lab/ui/e2e.js";
24
25
  import { SIGNAL_SUBPROTOCOL as SUB, parseWebrtcLink, type WebrtcLink } from "./webrtcLink.ts";
25
26
  import {
27
+ ConnectError,
26
28
  closeError,
27
29
  describeConnectFailure,
30
+ memberOf,
31
+ mergeMemberLs,
32
+ otherMembers,
28
33
  reduceSignal,
29
34
  socketError,
35
+ splitMemberKeyword,
30
36
  timeoutError,
37
+ type RoomMember,
31
38
  type SignalState,
32
39
  } from "./webrtcSignal.ts";
33
40
 
@@ -35,6 +42,12 @@ export { isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
35
42
  export type { WebrtcLink } from "./webrtcLink.ts";
36
43
 
37
44
  const CONNECT_TIMEOUT_MS = 25_000;
45
+ // How long to wait for a direct data channel before using the signaling relay
46
+ // (only when both ends opted in with AGENT_YES_SIGNAL_RELAY=1).
47
+ const RELAY_AFTER_MS = 8_000;
48
+
49
+ const signalRelayEnabled = () =>
50
+ ["1", "true", "yes"].includes((process.env.AGENT_YES_SIGNAL_RELAY ?? "").trim());
38
51
 
39
52
  interface PendingReq {
40
53
  onRes: (status: number, ct: string) => void;
@@ -42,9 +55,14 @@ interface PendingReq {
42
55
  onEnd: (error?: string) => void;
43
56
  }
44
57
 
45
- /** A live, key-confirmed WebRTC connection to a single share room. */
46
- class WebRtcConn {
47
- private ws: WebSocket;
58
+ /** Where a member connection sends its signaling frames (addressed to it). */
59
+ type SignalPort = (msg: Record<string, unknown>) => void;
60
+
61
+ /**
62
+ * A live, key-confirmed WebRTC connection to one host (member) of a share
63
+ * room. Its signaling travels over the room's shared socket (RoomSignal).
64
+ */
65
+ class HostConn {
48
66
  private pc: any = null;
49
67
  private dc: any = null;
50
68
  private readonly send = { sendCtr: 0n };
@@ -52,16 +70,14 @@ class WebRtcConn {
52
70
  private keyC2H: CryptoKey | null = null; // client encrypts host-bound frames
53
71
  private keyH2C: CryptoKey | null = null; // client decrypts host-sent frames
54
72
  private th: Uint8Array | null = null;
55
- private hostPeer: string | null = null;
56
73
  private readonly myNonce = randomHex(16);
57
74
  private confirmedIn = false;
58
75
  private confirmedOut = false;
59
76
  private confirmed = false;
60
77
  // Join progress, so a failure names the phase it happened in (webrtcSignal.ts).
61
- private readonly sig: SignalState = { opened: false, offered: false, confirmed: false };
78
+ readonly sig: SignalState = { opened: true, offered: false, confirmed: false };
62
79
  // Host candidates that trickled in before the offer; applied once the peer exists.
63
80
  private earlyCandidates: any[] = [];
64
- private warnedDuplicateHost = false;
65
81
  private sendChain: Promise<void> = Promise.resolve();
66
82
  private recvChain: Promise<void> = Promise.resolve();
67
83
  private idCounter = 0;
@@ -69,13 +85,21 @@ class WebRtcConn {
69
85
 
70
86
  readonly ready: Promise<void>;
71
87
  private resolveReady!: () => void;
72
- private rejectReady!: (e: Error) => void;
88
+ rejectReady!: (e: Error) => void;
73
89
 
74
- constructor(private readonly link: WebrtcLink) {
90
+ constructor(
91
+ private readonly link: WebrtcLink,
92
+ readonly member: RoomMember,
93
+ private readonly signal: SignalPort,
94
+ ) {
75
95
  this.ready = new Promise<void>((res, rej) => {
76
96
  this.resolveReady = res;
77
97
  this.rejectReady = rej;
78
98
  });
99
+ this.ready.then(
100
+ () => (this.state = "ok"),
101
+ (e) => ((this.state = "failed"), (this.error = e)),
102
+ );
79
103
  const timer = setTimeout(
80
104
  () => this.rejectReady(timeoutError(this.sig, CONNECT_TIMEOUT_MS)),
81
105
  CONNECT_TIMEOUT_MS,
@@ -84,104 +108,96 @@ class WebRtcConn {
84
108
  () => clearTimeout(timer),
85
109
  () => clearTimeout(timer),
86
110
  );
87
- this.ws = this.dial();
88
111
  }
89
112
 
90
- private dial(): WebSocket {
91
- const { room, host } = this.link;
92
- const ws = new WebSocket(`wss://${host}/${room}`, [SUB]);
93
- ws.onopen = async () => {
94
- this.sig.opened = true;
95
- const authToken = await deriveAuthToken(this.link.s, room, host);
96
- ws.send(JSON.stringify({ type: "hello", role: "client", v: 2, token: authToken }));
97
- };
98
- ws.onerror = (e: any) => this.rejectReady(socketError(this.sig, String(e?.message ?? e)));
99
- ws.onclose = (ev: any) => {
100
- // The close code and reason are the server's own diagnosis (1008 "forbidden",
101
- // "room not open"); dropping them left only "closed before connect".
102
- if (!this.confirmed)
103
- this.rejectReady(closeError(this.sig, Number(ev?.code ?? 0), String(ev?.reason ?? "")));
104
- };
105
- ws.onmessage = (ev: any) => void this.onSignal(ev);
106
- return ws;
113
+ get isConfirmed(): boolean {
114
+ return this.confirmed;
107
115
  }
108
116
 
109
- private async onSignal(ev: any): Promise<void> {
110
- const m = JSON.parse(typeof ev.data === "string" ? ev.data : await ev.data.text());
111
- const action = reduceSignal(this.sig, m);
112
- if (action.kind === "fail") {
113
- this.rejectReady(action.error);
114
- return;
115
- }
116
- if (action.kind === "duplicate-offer") {
117
- // Two hosts in one room: our answer would go to both and neither
118
- // handshake completes. Stay with the first; say so once.
119
- if (!this.warnedDuplicateHost) {
120
- this.warnedDuplicateHost = true;
121
- process.stderr.write(
122
- `[webrtc] a second host offered in room ${this.link.room} — two hosts serve this room; ` +
123
- `on the host, stop the stray one (\`ay serve status\`)\n`,
124
- );
125
- }
126
- return;
127
- }
128
- if (action.kind === "candidate") {
129
- if (action.queue) this.earlyCandidates.push(action.candidate);
130
- else if (this.pc) await this.pc.addIceCandidate(action.candidate).catch(() => {});
131
- return;
132
- }
133
- if (action.kind === "offer") {
134
- this.sig.offered = true;
135
- this.hostPeer = action.from ?? null;
136
- const pc = new RTCPeerConnection({ iceServers: action.iceServers || [] });
137
- this.pc = pc;
138
- // Our candidates go out only after the answer: setLocalDescription below
139
- // fires onicecandidate before the answer is sent, and a host that gets a
140
- // candidate before the answer cannot add it (ayrs up to 1.302.x dropped
141
- // the whole peer over it).
142
- let held: any[] | null = [];
143
- const sendCandidate = (candidate: any) =>
144
- this.ws.send(JSON.stringify({ type: "candidate", to: this.hostPeer, candidate }));
145
- pc.onicecandidate = (e: any) => {
146
- if (!e.candidate) return;
147
- if (held) held.push(e.candidate);
148
- else sendCandidate(e.candidate);
117
+ /** Frames go over the signaling socket (see RelayState in rs/src/serve/share.rs). */
118
+ private relayed = false;
119
+
120
+ /** A sealed frame the host relayed over signaling. */
121
+ onRelay(data: string): void {
122
+ if (!this.relayed) return;
123
+ const bytes = Buffer.from(data, "base64");
124
+ this.recvChain = this.recvChain.then(() => this.onFrame(bytes)).catch(() => {});
125
+ }
126
+
127
+ state: "pending" | "ok" | "failed" = "pending";
128
+ error: Error | null = null;
129
+
130
+ async onCandidate(candidate: any): Promise<void> {
131
+ if (!this.pc) this.earlyCandidates.push(candidate);
132
+ else await this.pc.addIceCandidate(candidate).catch(() => {});
133
+ }
134
+
135
+ async onOffer(sdp: string, iceServers: any[], relay = false): Promise<void> {
136
+ if (this.sig.offered) return; // a repeat offer from the same host
137
+ this.sig.offered = true;
138
+ const pc = new RTCPeerConnection({ iceServers: iceServers || [] });
139
+ this.pc = pc;
140
+ // Our candidates go out only after the answer: setLocalDescription below
141
+ // fires onicecandidate before the answer is sent, and a host that gets a
142
+ // candidate before the answer cannot add it (ayrs up to 1.302.x dropped
143
+ // the whole peer over it).
144
+ let held: any[] | null = [];
145
+ const sendCandidate = (candidate: any) => this.signal({ type: "candidate", candidate });
146
+ pc.onicecandidate = (e: any) => {
147
+ if (!e.candidate) return;
148
+ if (held) held.push(e.candidate);
149
+ else sendCandidate(e.candidate);
150
+ };
151
+ pc.ondatachannel = (e: any) => {
152
+ const dc = e.channel;
153
+ this.dc = dc;
154
+ dc.binaryType = "arraybuffer";
155
+ dc.onopen = () => {
156
+ if (!this.relayed)
157
+ void this.enqueueSeal(FLAG_CONFIRM, { t: "confirm", nonce: this.myNonce });
149
158
  };
150
- pc.ondatachannel = (e: any) => {
151
- const dc = e.channel;
152
- this.dc = dc;
153
- dc.binaryType = "arraybuffer";
154
- dc.onopen = () => this.enqueueSeal(FLAG_CONFIRM, { t: "confirm", nonce: this.myNonce });
155
- dc.onmessage = (ev2: any) => {
156
- this.recvChain = this.recvChain.then(() => this.onFrame(ev2.data)).catch(() => {});
157
- };
159
+ dc.onmessage = (ev2: any) => {
160
+ this.recvChain = this.recvChain.then(() => this.onFrame(ev2.data)).catch(() => {});
158
161
  };
159
- await pc.setRemoteDescription({ type: "offer", sdp: action.sdp });
160
- // Candidates the host trickled before its offer (the Rust host does) are
161
- // only valid once the remote description is set — flush them now, or the
162
- // host's LAN routes are lost and the handshake limps to a timeout.
163
- const early = this.earlyCandidates;
164
- this.earlyCandidates = [];
165
- for (const c of early) await pc.addIceCandidate(c).catch(() => {});
166
- const answer = await pc.createAnswer();
167
- await pc.setLocalDescription(answer);
168
- this.th = await computeTranscriptHash(action.sdp, pc.localDescription.sdp);
169
- const keys = await deriveDirKeys(this.link.s, this.th);
170
- this.keyC2H = keys.keyC2H;
171
- this.keyH2C = keys.keyH2C;
172
- this.ws.send(
173
- JSON.stringify({ type: "answer", to: this.hostPeer, sdp: pc.localDescription.sdp }),
174
- );
175
- const queued = held;
176
- held = null;
177
- for (const c of queued) sendCandidate(c);
178
- }
162
+ };
163
+ await pc.setRemoteDescription({ type: "offer", sdp });
164
+ // Candidates the host trickled before its offer (the Rust host does) are
165
+ // only valid once the remote description is set — flush them now, or the
166
+ // host's LAN routes are lost and the handshake limps to a timeout.
167
+ const early = this.earlyCandidates;
168
+ this.earlyCandidates = [];
169
+ for (const c of early) await pc.addIceCandidate(c).catch(() => {});
170
+ const answer = await pc.createAnswer();
171
+ await pc.setLocalDescription(answer);
172
+ this.th = await computeTranscriptHash(sdp, pc.localDescription.sdp);
173
+ const keys = await deriveDirKeys(this.link.s, this.th);
174
+ this.keyC2H = keys.keyC2H;
175
+ this.keyH2C = keys.keyH2C;
176
+ this.signal({ type: "answer", sdp: pc.localDescription.sdp });
177
+ const queued = held;
178
+ held = null;
179
+ for (const c of queued) sendCandidate(c);
180
+ // No direct path yet: fall back to the signaling relay, when both ends allow it.
181
+ if (relay && signalRelayEnabled())
182
+ setTimeout(() => {
183
+ if (this.confirmed || this.dc?.readyState === "open") return;
184
+ process.stderr.write(
185
+ `[webrtc] no direct path to ${this.member.name || this.member.peer}; using the signaling relay\n`,
186
+ );
187
+ this.relayed = true;
188
+ void this.enqueueSeal(FLAG_CONFIRM, { t: "confirm", nonce: this.myNonce });
189
+ }, RELAY_AFTER_MS).unref?.();
179
190
  }
180
191
 
181
192
  private enqueueSeal(flags: number, obj: object): Promise<void> {
182
193
  this.sendChain = this.sendChain.then(async () => {
183
- if (!this.dc || this.dc.readyState !== "open" || !this.keyC2H || !this.th) return;
194
+ if (!this.keyC2H || !this.th) return;
195
+ if (!this.relayed && (!this.dc || this.dc.readyState !== "open")) return;
184
196
  const frame = await e2eSeal(this.keyC2H, this.send, flags, this.th, packEnvelope(obj));
197
+ if (this.relayed) {
198
+ this.signal({ type: "relay", data: Buffer.from(frame).toString("base64") });
199
+ return;
200
+ }
185
201
  try {
186
202
  this.dc.send(frame);
187
203
  } catch {}
@@ -283,11 +299,185 @@ class WebRtcConn {
283
299
 
284
300
  close(): void {
285
301
  try {
286
- this.ws.close();
302
+ this.pc?.close();
287
303
  } catch {}
304
+ }
305
+ }
306
+
307
+ /**
308
+ * The room's signaling socket, shared by one HostConn per member. A room can
309
+ * have several hosts (lab/ui/cf/roomRouting.ts); the welcome lists them, and
310
+ * each one offers to us on its own. An older signaling server lists nobody and
311
+ * has one host, "host".
312
+ */
313
+ class RoomSignal {
314
+ private readonly ws: WebSocket;
315
+ private readonly sig: SignalState = { opened: false, offered: false, confirmed: false };
316
+ /** Members the welcome named; null until it arrives or when the server predates rooms with several hosts. */
317
+ private roster: RoomMember[] | null = null;
318
+ readonly conns = new Map<string, HostConn>();
319
+ private settled = false;
320
+ /** Resolves with the members that connected, once every one has connected or failed. */
321
+ readonly ready: Promise<HostConn[]>;
322
+ private resolveReady!: (c: HostConn[]) => void;
323
+ private rejectReady!: (e: Error) => void;
324
+
325
+ constructor(
326
+ private readonly link: WebrtcLink,
327
+ /** Members to skip (this machine's own host). */
328
+ private readonly skip: (m: RoomMember) => boolean,
329
+ ) {
330
+ this.ready = new Promise<HostConn[]>((res, rej) => {
331
+ this.resolveReady = res;
332
+ this.rejectReady = rej;
333
+ });
334
+ const timer = setTimeout(
335
+ () => this.fail(timeoutError(this.sig, CONNECT_TIMEOUT_MS)),
336
+ CONNECT_TIMEOUT_MS + 1000,
337
+ );
338
+ this.ready.then(
339
+ () => clearTimeout(timer),
340
+ () => clearTimeout(timer),
341
+ );
342
+ this.ws = this.dial();
343
+ }
344
+
345
+ private fail(e: Error): void {
346
+ if (this.settled) return;
347
+ // Members that already connected are still worth using.
348
+ if (this.connected().length) return this.settle();
349
+ this.settled = true;
350
+ for (const c of this.conns.values()) c.rejectReady(e);
351
+ this.rejectReady(e);
352
+ }
353
+
354
+ private connected(): HostConn[] {
355
+ return [...this.conns.values()].filter((c) => c.isConfirmed);
356
+ }
357
+
358
+ private settle(): void {
359
+ if (this.settled) return;
360
+ this.settled = true;
361
+ this.resolveReady(this.connected());
362
+ }
363
+
364
+ /** Settle once every member we expect has connected or failed. */
365
+ private check(): void {
366
+ if (this.settled || !this.roster) return;
367
+ const expected = this.roster.filter((m) => !this.skip(m));
368
+ const conns = expected.map((m) => this.conns.get(m.peer));
369
+ if (!expected.length || conns.some((c) => !c || c.state === "pending")) return;
370
+ if (this.connected().length) return this.settle();
371
+ this.fail(conns.find((c) => c!.error)?.error ?? new Error("no host connected"));
372
+ }
373
+
374
+ private dial(): WebSocket {
375
+ const { room, host } = this.link;
376
+ // A local signaling server (wrangler dev) speaks plain ws, like ayrs's.
377
+ const scheme = /^(localhost|127\.)/.test(host) ? "ws" : "wss";
378
+ const ws = new WebSocket(`${scheme}://${host}/${room}`, [SUB]);
379
+ ws.onopen = async () => {
380
+ this.sig.opened = true;
381
+ const authToken = await deriveAuthToken(this.link.s, room, host);
382
+ ws.send(
383
+ JSON.stringify({ type: "hello", role: "client", v: 2, token: authToken, multi: true }),
384
+ );
385
+ };
386
+ ws.onerror = (e: any) => this.fail(socketError(this.sig, String(e?.message ?? e)));
387
+ ws.onclose = (ev: any) => {
388
+ // The close code and reason are the server's own diagnosis (1008 "forbidden",
389
+ // "room not open"); dropping them left only "closed before connect".
390
+ this.fail(closeError(this.sig, Number(ev?.code ?? 0), String(ev?.reason ?? "")));
391
+ };
392
+ ws.onmessage = (ev: any) => void this.onSignal(ev);
393
+ return ws;
394
+ }
395
+
396
+ private memberConn(peer: string): HostConn | null {
397
+ let c = this.conns.get(peer);
398
+ if (c) return c;
399
+ if (this.settled) return null; // a member that arrived after we chose
400
+ const member = this.roster?.find((m) => m.peer === peer) ?? { peer, name: "" };
401
+ if (this.skip(member)) return null;
402
+ c = new HostConn(this.link, member, (msg) =>
403
+ this.ws.send(JSON.stringify({ ...msg, to: peer })),
404
+ );
405
+ this.conns.set(peer, c);
406
+ c.ready.then(
407
+ () => this.check(),
408
+ () => this.check(),
409
+ );
410
+ // An older server has no roster: its one host is all there is.
411
+ if (!this.roster)
412
+ c.ready.then(
413
+ () => this.settle(),
414
+ (e) => this.fail(e),
415
+ );
416
+ return c;
417
+ }
418
+
419
+ private async onSignal(ev: any): Promise<void> {
420
+ const m = JSON.parse(typeof ev.data === "string" ? ev.data : await ev.data.text());
421
+ if (m?.type === "welcome") {
422
+ if (Array.isArray(m.hosts)) {
423
+ this.roster = m.hosts
424
+ .filter((h: any) => h && typeof h.peer === "string")
425
+ .map((h: any) => ({ peer: h.peer, name: String(h.name ?? "") }));
426
+ // Ask each member we want for a connection; it then offers.
427
+ for (const h of this.roster!) this.join(h);
428
+ }
429
+ const action = reduceSignal(this.sig, m);
430
+ if (action.kind === "fail") this.fail(action.error);
431
+ return;
432
+ }
433
+ if (m?.type === "host-join" && this.roster && typeof m.peer === "string") {
434
+ if (!this.roster.some((h) => h.peer === m.peer)) {
435
+ const h = { peer: m.peer, name: String(m.name ?? "") };
436
+ this.roster.push(h);
437
+ this.join(h);
438
+ }
439
+ return;
440
+ }
441
+ if (m?.type === "host-leave" && this.roster && typeof m.peer === "string") {
442
+ this.roster = this.roster.filter((h) => h.peer !== m.peer);
443
+ this.conns.get(m.peer)?.rejectReady(new ConnectError("no-host", "the host left the room"));
444
+ this.check();
445
+ return;
446
+ }
447
+ if (m?.type === "offer") {
448
+ this.sig.offered = true;
449
+ await this.memberConn(memberOf(m))?.onOffer(m.sdp, m.iceServers || [], m.relay === true);
450
+ } else if (m?.type === "candidate") {
451
+ await this.memberConn(memberOf(m))?.onCandidate(m.candidate);
452
+ } else if (m?.type === "relay" && typeof m.data === "string") {
453
+ this.conns.get(memberOf(m))?.onRelay(m.data);
454
+ }
455
+ }
456
+
457
+ private join(m: RoomMember): void {
458
+ if (this.settled || this.skip(m) || this.conns.has(m.peer)) return;
459
+ this.ws.send(JSON.stringify({ type: "join", to: m.peer }));
460
+ }
461
+
462
+ /** Every member the room listed (or the one that offered, on an older server). */
463
+ members(): RoomMember[] {
464
+ return this.roster ?? [...this.conns.values()].map((c) => c.member);
465
+ }
466
+
467
+ /** Drop the members we will not use, so their hosts can free the peer. */
468
+ keepOnly(keep: HostConn[]): void {
469
+ for (const [peer, c] of this.conns)
470
+ if (!keep.includes(c)) {
471
+ c.close();
472
+ this.conns.delete(peer);
473
+ }
474
+ }
475
+
476
+ close(): void {
288
477
  try {
289
- this.pc?.close();
478
+ this.ws.close();
290
479
  } catch {}
480
+ for (const c of this.conns.values()) c.close();
291
481
  }
292
482
  }
293
483
 
@@ -295,6 +485,54 @@ export interface WebrtcBridge {
295
485
  baseUrl: string;
296
486
  token: string;
297
487
  close: () => void;
488
+ /** The room's members (one, for a room with a single host). */
489
+ members: RoomMember[];
490
+ /** The keyword left once a leading `<member>:` picked the member. */
491
+ keyword?: string;
492
+ }
493
+
494
+ /** This machine's member name, as ayrs reports it (rs/src/serve/room_member.rs). */
495
+ function selfMemberName(): string {
496
+ const n = process.env.AY_NAME?.trim();
497
+ return n || hostname();
498
+ }
499
+
500
+ async function readJson(conn: HostConn, path: string): Promise<unknown> {
501
+ const { status, stream } = await conn.request("GET", path);
502
+ const text = await new Response(stream).text();
503
+ if (status < 200 || status >= 300) throw new Error(`${status} ${text.slice(0, 200)}`);
504
+ return JSON.parse(text);
505
+ }
506
+
507
+ /**
508
+ * The member a `<alias>:<keyword>` reference means in a room with several
509
+ * hosts: the one named by a leading `<member>:`, else the only one with an
510
+ * agent matching the keyword. Throws with the candidates when that is not
511
+ * exactly one.
512
+ */
513
+ async function chooseMember(
514
+ conns: HostConn[],
515
+ keyword: string,
516
+ ): Promise<{ conn: HostConn; keyword: string | undefined }> {
517
+ const members = conns.map((c) => c.member);
518
+ const named = splitMemberKeyword(keyword, members);
519
+ if (named) return { conn: conns.find((c) => c.member === named.member)!, keyword: named.keyword };
520
+ const q = `/api/ls?all=1&keyword=${encodeURIComponent(keyword)}`;
521
+ const hits = await Promise.all(
522
+ conns.map(async (c) => {
523
+ const rows = await readJson(c, q).catch(() => []);
524
+ return Array.isArray(rows) && rows.length ? c : null;
525
+ }),
526
+ );
527
+ const found = hits.filter((c): c is HostConn => !!c);
528
+ if (found.length === 1) return { conn: found[0]!, keyword };
529
+ const names = (found.length ? found : conns).map((c) => c.member.name || c.member.peer);
530
+ throw new Error(
531
+ found.length
532
+ ? `"${keyword}" matches agents on several hosts in this room: ${names.join(", ")} — ` +
533
+ `say which as <remote>:<host>:${keyword}`
534
+ : `no agent matches "${keyword}" on any host in this room (${names.join(", ")})`,
535
+ );
298
536
  }
299
537
 
300
538
  /**
@@ -302,14 +540,32 @@ export interface WebrtcBridge {
302
540
  * request over the encrypted DataChannel. Returns the loopback base URL the
303
541
  * existing remote commands can `fetch()` against. The process owns teardown:
304
542
  * `ay` subcommands `process.exit()` when done, which closes the socket/peer.
543
+ *
544
+ * A room with several hosts: with a `keyword`, the bridge talks to the one
545
+ * member it means (see chooseMember); without one, `GET /api/ls` lists every
546
+ * member's agents (each row tagged `member`) and anything else is refused
547
+ * with a hint to name the member.
305
548
  */
306
- export async function startWebrtcBridge(link: string): Promise<WebrtcBridge> {
549
+ export async function startWebrtcBridge(
550
+ link: string,
551
+ opts: { keyword?: string } = {},
552
+ ): Promise<WebrtcBridge> {
307
553
  const parsed = parseWebrtcLink(link);
308
554
  if (!parsed) throw new Error(`not a WebRTC share link: ${link}`);
309
- const conn = new WebRtcConn(parsed);
555
+ const self = selfMemberName();
556
+ // Skip this machine's own member, but only while others exist (decided
557
+ // per member as offers arrive, so a one-host room still works from itself).
558
+ const room: RoomSignal = new RoomSignal(
559
+ parsed,
560
+ (m) =>
561
+ m.name === self &&
562
+ otherMembers(room?.members?.() ?? [m], self).every((o) => o.peer !== m.peer),
563
+ );
564
+ let conns: HostConn[];
310
565
  try {
311
- await conn.ready;
566
+ conns = await room.ready;
312
567
  } catch (e) {
568
+ room.close();
313
569
  // Every remote command routes through here, so this is the one place a
314
570
  // signaling failure has to become actionable. The failure names its phase
315
571
  // (webrtcSignal.ts); only a socket that never opened gets the HTTPS-probe
@@ -323,6 +579,36 @@ export async function startWebrtcBridge(link: string): Promise<WebrtcBridge> {
323
579
  );
324
580
  }
325
581
 
582
+ let keyword = opts.keyword;
583
+ // `<member>:<agent>` names its member even when only one member is
584
+ // connected (a member skips itself), and naming this machine is a mistake
585
+ // worth saying so: its agents are local, not in the room bridge.
586
+ const named = keyword ? splitMemberKeyword(keyword, room.members()) : null;
587
+ if (named) {
588
+ const conn = conns.find((c) => c.member.peer === named.member.peer);
589
+ if (!conn) {
590
+ room.close();
591
+ throw new Error(
592
+ named.member.name === self
593
+ ? `"${named.member.name}" is this machine — address its agents locally (ay send <agent>)`
594
+ : `room member "${named.member.name || named.member.peer}" did not connect`,
595
+ );
596
+ }
597
+ conns = [conn];
598
+ keyword = named.keyword;
599
+ } else if (conns.length > 1 && keyword) {
600
+ try {
601
+ const pick = await chooseMember(conns, keyword);
602
+ conns = [pick.conn];
603
+ keyword = pick.keyword;
604
+ } catch (e) {
605
+ room.close();
606
+ throw e;
607
+ }
608
+ }
609
+ room.keepOnly(conns);
610
+ const members = room.members();
611
+
326
612
  const server = Bun.serve({
327
613
  port: 0,
328
614
  hostname: "127.0.0.1",
@@ -332,8 +618,24 @@ export async function startWebrtcBridge(link: string): Promise<WebrtcBridge> {
332
618
  const pathWithQuery = u.pathname + u.search;
333
619
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
334
620
  const body = hasBody ? await req.text() : undefined;
621
+ if (conns.length > 1) {
622
+ if (req.method === "GET" && u.pathname === "/api/ls") {
623
+ const parts = await Promise.all(
624
+ conns.map(async (c) => ({
625
+ member: c.member,
626
+ rows: await readJson(c, pathWithQuery).catch(() => []),
627
+ })),
628
+ );
629
+ return Response.json(mergeMemberLs(parts));
630
+ }
631
+ const names = conns.map((c) => c.member.name || c.member.peer).join(", ");
632
+ return new Response(
633
+ `this room has several hosts (${names}); address one as <remote>:<host>:<agent>`,
634
+ { status: 409 },
635
+ );
636
+ }
335
637
  try {
336
- const { status, ct, stream } = await conn.request(req.method, pathWithQuery, body);
638
+ const { status, ct, stream } = await conns[0]!.request(req.method, pathWithQuery, body);
337
639
  return new Response(stream, { status, headers: ct ? { "content-type": ct } : {} });
338
640
  } catch (e: any) {
339
641
  return new Response(String(e?.message ?? e), { status: 502 });
@@ -344,11 +646,13 @@ export async function startWebrtcBridge(link: string): Promise<WebrtcBridge> {
344
646
  return {
345
647
  baseUrl: `http://127.0.0.1:${server.port}`,
346
648
  token: "webrtc", // auth is the E2E secret; the host injects its own bearer
649
+ members,
650
+ keyword,
347
651
  close: () => {
348
652
  try {
349
653
  server.stop(true);
350
654
  } catch {}
351
- conn.close();
655
+ room.close();
352
656
  },
353
657
  };
354
658
  }