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.
- package/dist/{SUPPORTED_CLIS-FBshN0v2.js → SUPPORTED_CLIS-C8rpcE-P.js} +2 -2
- package/dist/{SUPPORTED_CLIS-C5Uhdl61.js → SUPPORTED_CLIS-ukR2iojt.js} +3 -3
- package/dist/{agentShare-CNgCQMY9.js → agentShare-BPz-DWhb.js} +4 -4
- package/dist/{callback-ayc9yXqJ.js → callback-CQOc7bl3.js} +3 -3
- package/dist/{callback-CbZ-H_Pn.js → callback-FvcCaQZP.js} +4 -4
- package/dist/cli.js +5 -5
- package/dist/{cmdPs-CDj-l5Pk.js → cmdPs-oZEpAa9v.js} +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DZbd5fTj.js → notifyDaemon-Dg4SZBXh.js} +4 -4
- package/dist/{parentPingLoop-Bim6u0OH.js → parentPingLoop-BjDipYVr.js} +2 -2
- package/dist/{remotes-ByjpYjQ9.js → remotes-C36aI8Ty.js} +2 -2
- package/dist/{remotes-D10E_xUn.js → remotes-COBEhmlr.js} +6 -6
- package/dist/{rustBinary-Btf18sk5.js → rustBinary-0phuQaIs.js} +24 -3
- package/dist/rustBinary-CZptL6HA.js +4 -0
- package/dist/{schedule-DzqTosvJ.js → schedule-CuCWCw4g.js} +4 -4
- package/dist/{serve-DdHbUWtG.js → serve-CQqPMRbO.js} +44 -27
- package/dist/{serveRust-BEzTqFRy.js → serveRust-BgY5Te5E.js} +50 -6
- package/dist/{setup-CzKkVYp8.js → setup-Dt7dBtlK.js} +2 -2
- package/dist/{shareCmd-DinezAHY.js → shareCmd-Dvtm6NbI.js} +7 -7
- package/dist/{subcommands-oIARj_mJ.js → subcommands-CCdEFHc_.js} +3 -3
- package/dist/{subcommands-fX4Ygx3a.js → subcommands-CGQhH5RX.js} +30 -27
- package/dist/{terminal-DlW0KNym.js → terminal-9q3wQawj.js} +2 -2
- package/dist/{trayApp-DWVdr0Pr.js → trayApp-BKDrpclL.js} +2 -2
- package/dist/trayApp-Du2MFg7l.js +5 -0
- package/dist/{ts-D29CMeWZ.js → ts-DfSvex_p.js} +3 -3
- package/dist/{versionChecker-DrjMn-XI.js → versionChecker-CLrLOjMI.js} +2 -2
- package/dist/webrtcLink-CHLgPKWj.js +4 -0
- package/dist/{webrtcLink-CKnjNGaV.js → webrtcLink-bWxiCBTV.js} +26 -9
- package/dist/webrtcRemote-RmkbzY6Y.js +683 -0
- package/dist/{widget-CvcvPWF9.js → widget-CKSKxvxy.js} +3 -3
- package/dist/{ws-DDB8jh0f.js → ws-BKJ17qZk.js} +4 -4
- package/dist/{ws-Cbx5sfsd.js → ws-DMx_Yw4Z.js} +2 -2
- package/lab/ui/index.html +71 -3
- package/package.json +1 -1
- package/ts/remotes.ts +11 -4
- package/ts/rustBinary.ts +19 -0
- package/ts/serve.ts +26 -4
- package/ts/serveRust.spec.ts +23 -6
- package/ts/serveRust.ts +34 -7
- package/ts/subcommands.ts +4 -1
- package/ts/webrtcLink.spec.ts +26 -1
- package/ts/webrtcLink.ts +32 -5
- package/ts/webrtcRemote.ts +406 -102
- package/ts/webrtcSignal.spec.ts +40 -0
- package/ts/webrtcSignal.ts +59 -3
- package/dist/rustBinary-DklBSefL.js +0 -4
- package/dist/trayApp-F_Xs3C2N.js +0 -5
- package/dist/webrtcLink-CAdvnRM0.js +0 -4
- package/dist/webrtcRemote-BYy2lTTl.js +0 -408
package/ts/webrtcRemote.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
rejectReady!: (e: Error) => void;
|
|
73
89
|
|
|
74
|
-
constructor(
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
655
|
+
room.close();
|
|
352
656
|
},
|
|
353
657
|
};
|
|
354
658
|
}
|