agent-yes 1.300.3 → 1.300.4

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 (58) hide show
  1. package/dist/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
  3. package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
  4. package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
  5. package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
  6. package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
  9. package/dist/index.js +2 -2
  10. package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
  11. package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
  12. package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
  13. package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
  14. package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
  15. package/dist/rustBinary-FhGG3Yfl.js +4 -0
  16. package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
  17. package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
  18. package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
  19. package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
  20. package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
  21. package/dist/share-nmgSIjYP.js +4 -0
  22. package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
  23. package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
  24. package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
  25. package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
  26. package/dist/trayApp-0RVjo43f.js +5 -0
  27. package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
  28. package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
  29. package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
  30. package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
  31. package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
  32. package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
  33. package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
  34. package/package.json +1 -1
  35. package/ts/remoteCache.bun.spec.ts +191 -0
  36. package/ts/remoteCache.spec.ts +191 -0
  37. package/ts/remoteCache.ts +193 -0
  38. package/ts/rustBinary.bun.spec.ts +39 -2
  39. package/ts/rustBinary.spec.ts +53 -2
  40. package/ts/rustBinary.ts +61 -3
  41. package/ts/serve.spec.ts +103 -0
  42. package/ts/serve.ts +100 -15
  43. package/ts/serveRust.spec.ts +24 -1
  44. package/ts/serveRust.ts +19 -0
  45. package/ts/share.bun.spec.ts +54 -2
  46. package/ts/share.spec.ts +54 -2
  47. package/ts/share.ts +40 -15
  48. package/ts/shareCmd.ts +33 -2
  49. package/ts/strayServe.ts +1 -0
  50. package/ts/subcommands.spec.ts +36 -3
  51. package/ts/subcommands.ts +384 -98
  52. package/ts/webrtcRemote.ts +66 -20
  53. package/ts/webrtcSignal.bun.spec.ts +120 -0
  54. package/ts/webrtcSignal.spec.ts +120 -0
  55. package/ts/webrtcSignal.ts +143 -0
  56. package/dist/rustBinary-BNmAJEBb.js +0 -4
  57. package/dist/share-Bmnd_qT0.js +0 -4
  58. package/dist/trayApp-BDIb8fOj.js +0 -5
@@ -22,6 +22,14 @@ import {
22
22
  FLAG_CONFIRM,
23
23
  } from "../lab/ui/e2e.js";
24
24
  import { SIGNAL_SUBPROTOCOL as SUB, parseWebrtcLink, type WebrtcLink } from "./webrtcLink.ts";
25
+ import {
26
+ closeError,
27
+ describeConnectFailure,
28
+ reduceSignal,
29
+ socketError,
30
+ timeoutError,
31
+ type SignalState,
32
+ } from "./webrtcSignal.ts";
25
33
 
26
34
  export { isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
27
35
  export type { WebrtcLink } from "./webrtcLink.ts";
@@ -49,6 +57,11 @@ class WebRtcConn {
49
57
  private confirmedIn = false;
50
58
  private confirmedOut = false;
51
59
  private confirmed = false;
60
+ // Join progress, so a failure names the phase it happened in (webrtcSignal.ts).
61
+ private readonly sig: SignalState = { opened: false, offered: false, confirmed: false };
62
+ // Host candidates that trickled in before the offer; applied once the peer exists.
63
+ private earlyCandidates: any[] = [];
64
+ private warnedDuplicateHost = false;
52
65
  private sendChain: Promise<void> = Promise.resolve();
53
66
  private recvChain: Promise<void> = Promise.resolve();
54
67
  private idCounter = 0;
@@ -64,7 +77,7 @@ class WebRtcConn {
64
77
  this.rejectReady = rej;
65
78
  });
66
79
  const timer = setTimeout(
67
- () => this.rejectReady(new Error(`WebRTC connect timeout after ${CONNECT_TIMEOUT_MS}ms`)),
80
+ () => this.rejectReady(timeoutError(this.sig, CONNECT_TIMEOUT_MS)),
68
81
  CONNECT_TIMEOUT_MS,
69
82
  );
70
83
  this.ready.then(
@@ -78,13 +91,16 @@ class WebRtcConn {
78
91
  const { room, host } = this.link;
79
92
  const ws = new WebSocket(`wss://${host}/${room}`, [SUB]);
80
93
  ws.onopen = async () => {
94
+ this.sig.opened = true;
81
95
  const authToken = await deriveAuthToken(this.link.s, room, host);
82
96
  ws.send(JSON.stringify({ type: "hello", role: "client", v: 2, token: authToken }));
83
97
  };
84
- ws.onerror = (e: any) =>
85
- this.rejectReady(new Error(`signaling error: ${String(e?.message ?? e)}`));
86
- ws.onclose = () => {
87
- if (!this.confirmed) this.rejectReady(new Error("signaling closed before connect"));
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 ?? "")));
88
104
  };
89
105
  ws.onmessage = (ev: any) => void this.onSignal(ev);
90
106
  return ws;
@@ -92,9 +108,32 @@ class WebRtcConn {
92
108
 
93
109
  private async onSignal(ev: any): Promise<void> {
94
110
  const m = JSON.parse(typeof ev.data === "string" ? ev.data : await ev.data.text());
95
- if (m.type === "offer") {
96
- this.hostPeer = m.from;
97
- const pc = new RTCPeerConnection({ iceServers: m.iceServers || [] });
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 || [] });
98
137
  this.pc = pc;
99
138
  pc.onicecandidate = (e: any) => {
100
139
  if (e.candidate)
@@ -111,18 +150,22 @@ class WebRtcConn {
111
150
  this.recvChain = this.recvChain.then(() => this.onFrame(ev2.data)).catch(() => {});
112
151
  };
113
152
  };
114
- await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
153
+ await pc.setRemoteDescription({ type: "offer", sdp: action.sdp });
154
+ // Candidates the host trickled before its offer (the Rust host does) are
155
+ // only valid once the remote description is set — flush them now, or the
156
+ // host's LAN routes are lost and the handshake limps to a timeout.
157
+ const early = this.earlyCandidates;
158
+ this.earlyCandidates = [];
159
+ for (const c of early) await pc.addIceCandidate(c).catch(() => {});
115
160
  const answer = await pc.createAnswer();
116
161
  await pc.setLocalDescription(answer);
117
- this.th = await computeTranscriptHash(m.sdp, pc.localDescription.sdp);
162
+ this.th = await computeTranscriptHash(action.sdp, pc.localDescription.sdp);
118
163
  const keys = await deriveDirKeys(this.link.s, this.th);
119
164
  this.keyC2H = keys.keyC2H;
120
165
  this.keyH2C = keys.keyH2C;
121
166
  this.ws.send(
122
167
  JSON.stringify({ type: "answer", to: this.hostPeer, sdp: pc.localDescription.sdp }),
123
168
  );
124
- } else if (m.type === "candidate" && this.pc) {
125
- await this.pc.addIceCandidate(m.candidate).catch(() => {});
126
169
  }
127
170
  }
128
171
 
@@ -159,6 +202,7 @@ class WebRtcConn {
159
202
  if (env.echo && env.echo === this.myNonce) this.confirmedIn = true;
160
203
  if (this.confirmedIn && this.confirmedOut) {
161
204
  this.confirmed = true;
205
+ this.sig.confirmed = true;
162
206
  this.resolveReady();
163
207
  }
164
208
  return;
@@ -258,14 +302,16 @@ export async function startWebrtcBridge(link: string): Promise<WebrtcBridge> {
258
302
  await conn.ready;
259
303
  } catch (e) {
260
304
  // Every remote command routes through here, so this is the one place a
261
- // signaling failure has to become actionable. The raw error names the URL
262
- // and says "failed to connect" — all true, none of it pointing at the cause
263
- // when the cause is the local environment rather than the network. Probe
264
- // and say which; see ts/signalingHint.ts for why it is derived rather than
265
- // guessed.
266
- const cause = String((e as Error)?.message ?? e);
267
- const probe = await probeSignalingHost(parsed.host);
268
- throw new Error(signalingFailureHint(cause, probe, parsed.host));
305
+ // signaling failure has to become actionable. The failure names its phase
306
+ // (webrtcSignal.ts); only a socket that never opened gets the HTTPS-probe
307
+ // sandbox/proxy hint (signalingHint.ts) — every later phase proves the
308
+ // WebSocket upgrade worked, so that hint would be a misdiagnosis there.
309
+ const err = e instanceof Error ? e : new Error(String(e));
310
+ throw new Error(
311
+ await describeConnectFailure(err, parsed.host, async (cause) =>
312
+ signalingFailureHint(cause, await probeSignalingHost(parsed.host), parsed.host),
313
+ ),
314
+ );
269
315
  }
270
316
 
271
317
  const server = Bun.serve({
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ ConnectError,
4
+ closeError,
5
+ describeConnectFailure,
6
+ reduceSignal,
7
+ socketError,
8
+ timeoutError,
9
+ type SignalState,
10
+ } from "./webrtcSignal.ts";
11
+
12
+ const fresh = (): SignalState => ({ opened: false, offered: false, confirmed: false });
13
+ const opened = (): SignalState => ({ opened: true, offered: false, confirmed: false });
14
+ const offered = (): SignalState => ({ opened: true, offered: true, confirmed: false });
15
+
16
+ // Every one of these used to be a 25 s "connect timeout" followed by a hint
17
+ // that blamed a sandbox. The server says which it is; the viewer must listen.
18
+ describe("reduceSignal", () => {
19
+ it("fails fast when the server says no host is attached", () => {
20
+ const a = reduceSignal(opened(), { type: "welcome", hostPresent: false });
21
+ expect(a.kind).toBe("fail");
22
+ if (a.kind === "fail") expect(a.error.phase).toBe("no-host");
23
+ expect(reduceSignal(opened(), { type: "welcome", hostPresent: true })).toEqual({
24
+ kind: "ignore",
25
+ });
26
+ expect(reduceSignal(opened(), { type: "welcome" })).toEqual({ kind: "ignore" });
27
+ });
28
+
29
+ it("accepts the first offer and refuses a second one (two hosts in a room)", () => {
30
+ expect(
31
+ reduceSignal(opened(), { type: "offer", sdp: "v=0", iceServers: [{ urls: "stun:x" }] }),
32
+ ).toEqual({
33
+ kind: "offer",
34
+ sdp: "v=0",
35
+ iceServers: [{ urls: "stun:x" }],
36
+ from: undefined,
37
+ });
38
+ expect(reduceSignal(offered(), { type: "offer", sdp: "v=0" })).toEqual({
39
+ kind: "duplicate-offer",
40
+ });
41
+ });
42
+
43
+ it("queues a candidate that arrives before the offer, applies one after", () => {
44
+ expect(reduceSignal(opened(), { type: "candidate", candidate: { c: 1 } })).toEqual({
45
+ kind: "candidate",
46
+ candidate: { c: 1 },
47
+ queue: true,
48
+ });
49
+ expect(reduceSignal(offered(), { type: "candidate", candidate: { c: 2 } })).toEqual({
50
+ kind: "candidate",
51
+ candidate: { c: 2 },
52
+ queue: false,
53
+ });
54
+ });
55
+
56
+ it("ignores anything else, including garbage", () => {
57
+ expect(reduceSignal(opened(), { type: "peer-join" })).toEqual({ kind: "ignore" });
58
+ expect(reduceSignal(opened(), null)).toEqual({ kind: "ignore" });
59
+ expect(reduceSignal(opened(), "nope")).toEqual({ kind: "ignore" });
60
+ });
61
+ });
62
+
63
+ describe("closeError / timeoutError / socketError", () => {
64
+ it("keeps the server's close code and reason, and names the phase", () => {
65
+ expect(closeError(fresh(), 1006, "")).toMatchObject({ phase: "ws-not-open" });
66
+ expect(closeError(opened(), 1008, "forbidden")).toMatchObject({
67
+ phase: "rejected",
68
+ message: expect.stringContaining("1008: forbidden"),
69
+ });
70
+ expect(closeError(opened(), 1000, "room not open")).toMatchObject({ phase: "rejected" });
71
+ expect(closeError(opened(), 1006, "")).toMatchObject({ phase: "no-host" });
72
+ expect(closeError(offered(), 1006, "")).toMatchObject({ phase: "ice-timeout" });
73
+ });
74
+
75
+ it("times out into the phase the join had reached", () => {
76
+ expect(timeoutError(fresh(), 25_000)).toMatchObject({ phase: "ws-not-open" });
77
+ expect(timeoutError(opened(), 25_000)).toMatchObject({
78
+ phase: "no-host",
79
+ message: expect.stringContaining("25s"),
80
+ });
81
+ expect(timeoutError(offered(), 25_000)).toMatchObject({ phase: "ice-timeout" });
82
+ });
83
+
84
+ it("treats a socket error before open as the socket not opening", () => {
85
+ expect(socketError(fresh(), "ECONNRESET")).toMatchObject({ phase: "ws-not-open" });
86
+ expect(socketError(opened(), "ECONNRESET")).toMatchObject({ phase: "rejected" });
87
+ });
88
+ });
89
+
90
+ describe("describeConnectFailure", () => {
91
+ const host = "s.example";
92
+ const sandbox = async (cause: string) => `SANDBOX-HINT(${cause})`;
93
+
94
+ it("consults the sandbox hint only for a socket that never opened", async () => {
95
+ expect(await describeConnectFailure(new ConnectError("ws-not-open", "x"), host, sandbox)).toBe(
96
+ "SANDBOX-HINT(x)",
97
+ );
98
+ // A plain Error (no phase) is treated the same way — nothing is known.
99
+ expect(await describeConnectFailure(new Error("y"), host, sandbox)).toBe("SANDBOX-HINT(y)");
100
+ for (const phase of ["no-host", "rejected", "ice-timeout"] as const) {
101
+ const text = await describeConnectFailure(new ConnectError(phase, "cause"), host, sandbox);
102
+ expect(text).not.toContain("SANDBOX");
103
+ expect(text).not.toMatch(/sandbox.*permission/i);
104
+ expect(text).toContain("cause");
105
+ expect(text).toContain(host);
106
+ }
107
+ });
108
+
109
+ it("tells the operator what to do on the host for each phase", async () => {
110
+ expect(await describeConnectFailure(new ConnectError("no-host", "c"), host, sandbox)).toContain(
111
+ "ay share status",
112
+ );
113
+ expect(
114
+ await describeConnectFailure(new ConnectError("rejected", "c"), host, sandbox),
115
+ ).toContain("re-add the remote");
116
+ expect(
117
+ await describeConnectFailure(new ConnectError("ice-timeout", "c"), host, sandbox),
118
+ ).toContain("not a sandbox");
119
+ });
120
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ConnectError,
4
+ closeError,
5
+ describeConnectFailure,
6
+ reduceSignal,
7
+ socketError,
8
+ timeoutError,
9
+ type SignalState,
10
+ } from "./webrtcSignal.ts";
11
+
12
+ const fresh = (): SignalState => ({ opened: false, offered: false, confirmed: false });
13
+ const opened = (): SignalState => ({ opened: true, offered: false, confirmed: false });
14
+ const offered = (): SignalState => ({ opened: true, offered: true, confirmed: false });
15
+
16
+ // Every one of these used to be a 25 s "connect timeout" followed by a hint
17
+ // that blamed a sandbox. The server says which it is; the viewer must listen.
18
+ describe("reduceSignal", () => {
19
+ it("fails fast when the server says no host is attached", () => {
20
+ const a = reduceSignal(opened(), { type: "welcome", hostPresent: false });
21
+ expect(a.kind).toBe("fail");
22
+ if (a.kind === "fail") expect(a.error.phase).toBe("no-host");
23
+ expect(reduceSignal(opened(), { type: "welcome", hostPresent: true })).toEqual({
24
+ kind: "ignore",
25
+ });
26
+ expect(reduceSignal(opened(), { type: "welcome" })).toEqual({ kind: "ignore" });
27
+ });
28
+
29
+ it("accepts the first offer and refuses a second one (two hosts in a room)", () => {
30
+ expect(
31
+ reduceSignal(opened(), { type: "offer", sdp: "v=0", iceServers: [{ urls: "stun:x" }] }),
32
+ ).toEqual({
33
+ kind: "offer",
34
+ sdp: "v=0",
35
+ iceServers: [{ urls: "stun:x" }],
36
+ from: undefined,
37
+ });
38
+ expect(reduceSignal(offered(), { type: "offer", sdp: "v=0" })).toEqual({
39
+ kind: "duplicate-offer",
40
+ });
41
+ });
42
+
43
+ it("queues a candidate that arrives before the offer, applies one after", () => {
44
+ expect(reduceSignal(opened(), { type: "candidate", candidate: { c: 1 } })).toEqual({
45
+ kind: "candidate",
46
+ candidate: { c: 1 },
47
+ queue: true,
48
+ });
49
+ expect(reduceSignal(offered(), { type: "candidate", candidate: { c: 2 } })).toEqual({
50
+ kind: "candidate",
51
+ candidate: { c: 2 },
52
+ queue: false,
53
+ });
54
+ });
55
+
56
+ it("ignores anything else, including garbage", () => {
57
+ expect(reduceSignal(opened(), { type: "peer-join" })).toEqual({ kind: "ignore" });
58
+ expect(reduceSignal(opened(), null)).toEqual({ kind: "ignore" });
59
+ expect(reduceSignal(opened(), "nope")).toEqual({ kind: "ignore" });
60
+ });
61
+ });
62
+
63
+ describe("closeError / timeoutError / socketError", () => {
64
+ it("keeps the server's close code and reason, and names the phase", () => {
65
+ expect(closeError(fresh(), 1006, "")).toMatchObject({ phase: "ws-not-open" });
66
+ expect(closeError(opened(), 1008, "forbidden")).toMatchObject({
67
+ phase: "rejected",
68
+ message: expect.stringContaining("1008: forbidden"),
69
+ });
70
+ expect(closeError(opened(), 1000, "room not open")).toMatchObject({ phase: "rejected" });
71
+ expect(closeError(opened(), 1006, "")).toMatchObject({ phase: "no-host" });
72
+ expect(closeError(offered(), 1006, "")).toMatchObject({ phase: "ice-timeout" });
73
+ });
74
+
75
+ it("times out into the phase the join had reached", () => {
76
+ expect(timeoutError(fresh(), 25_000)).toMatchObject({ phase: "ws-not-open" });
77
+ expect(timeoutError(opened(), 25_000)).toMatchObject({
78
+ phase: "no-host",
79
+ message: expect.stringContaining("25s"),
80
+ });
81
+ expect(timeoutError(offered(), 25_000)).toMatchObject({ phase: "ice-timeout" });
82
+ });
83
+
84
+ it("treats a socket error before open as the socket not opening", () => {
85
+ expect(socketError(fresh(), "ECONNRESET")).toMatchObject({ phase: "ws-not-open" });
86
+ expect(socketError(opened(), "ECONNRESET")).toMatchObject({ phase: "rejected" });
87
+ });
88
+ });
89
+
90
+ describe("describeConnectFailure", () => {
91
+ const host = "s.example";
92
+ const sandbox = async (cause: string) => `SANDBOX-HINT(${cause})`;
93
+
94
+ it("consults the sandbox hint only for a socket that never opened", async () => {
95
+ expect(await describeConnectFailure(new ConnectError("ws-not-open", "x"), host, sandbox)).toBe(
96
+ "SANDBOX-HINT(x)",
97
+ );
98
+ // A plain Error (no phase) is treated the same way — nothing is known.
99
+ expect(await describeConnectFailure(new Error("y"), host, sandbox)).toBe("SANDBOX-HINT(y)");
100
+ for (const phase of ["no-host", "rejected", "ice-timeout"] as const) {
101
+ const text = await describeConnectFailure(new ConnectError(phase, "cause"), host, sandbox);
102
+ expect(text).not.toContain("SANDBOX");
103
+ expect(text).not.toMatch(/sandbox.*permission/i);
104
+ expect(text).toContain("cause");
105
+ expect(text).toContain(host);
106
+ }
107
+ });
108
+
109
+ it("tells the operator what to do on the host for each phase", async () => {
110
+ expect(await describeConnectFailure(new ConnectError("no-host", "c"), host, sandbox)).toContain(
111
+ "ay share status",
112
+ );
113
+ expect(
114
+ await describeConnectFailure(new ConnectError("rejected", "c"), host, sandbox),
115
+ ).toContain("re-add the remote");
116
+ expect(
117
+ await describeConnectFailure(new ConnectError("ice-timeout", "c"), host, sandbox),
118
+ ).toContain("not a sandbox");
119
+ });
120
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The CLI viewer's signaling decisions, as pure functions.
3
+ *
4
+ * Every way a WebRTC join can fail used to surface as one thing: a 25 s
5
+ * "connect timeout", followed by a hint that blamed a sandbox. The server
6
+ * actually tells the viewer which it is — `welcome.hostPresent:false` when no
7
+ * host is attached, a 1008 close with a reason when the join is rejected — and
8
+ * the handshake itself has observable phases (socket open, offer received,
9
+ * channel confirmed). Keeping that bookkeeping here, away from the socket and
10
+ * peer-connection plumbing in webrtcRemote.ts, makes each branch testable
11
+ * without a network, and keeps the sandbox hint for the one case it is true.
12
+ */
13
+
14
+ /** Where the join was when it failed; drives the operator-facing explanation. */
15
+ export type ConnectPhase =
16
+ /** The signaling WebSocket never opened. The only phase a sandbox explains. */
17
+ | "ws-not-open"
18
+ /** The socket opened but no host is attached to the room / none offered. */
19
+ | "no-host"
20
+ /** The signaling server rejected the join (wrong secret, room not open…). */
21
+ | "rejected"
22
+ /** Signaling worked and the host offered, but no data channel came up. */
23
+ | "ice-timeout";
24
+
25
+ export class ConnectError extends Error {
26
+ constructor(
27
+ readonly phase: ConnectPhase,
28
+ message: string,
29
+ ) {
30
+ super(message);
31
+ this.name = "ConnectError";
32
+ }
33
+ }
34
+
35
+ /** The viewer's progress through the join, updated by the caller. */
36
+ export interface SignalState {
37
+ opened: boolean;
38
+ offered: boolean;
39
+ confirmed: boolean;
40
+ }
41
+
42
+ export type SignalAction =
43
+ | { kind: "ignore" }
44
+ | { kind: "fail"; error: ConnectError }
45
+ /** The first offer: build the peer connection and answer it. */
46
+ | { kind: "offer"; sdp: string; iceServers: any[]; from?: string }
47
+ /** A second offer — two hosts in one room. Answering both breaks the handshake. */
48
+ | { kind: "duplicate-offer" }
49
+ /** A host candidate: `queue` when it arrived before the offer (the Rust host
50
+ * may trickle candidates first), else apply it. */
51
+ | { kind: "candidate"; candidate: any; queue: boolean };
52
+
53
+ /** Classify one signaling frame against the current state. */
54
+ export function reduceSignal(state: SignalState, m: any): SignalAction {
55
+ if (!m || typeof m !== "object") return { kind: "ignore" };
56
+ switch (m.type) {
57
+ case "welcome":
58
+ // The browser console fails fast on this; the CLI used to wait out the
59
+ // whole timeout and then blame the network.
60
+ if (m.hostPresent === false)
61
+ return {
62
+ kind: "fail",
63
+ error: new ConnectError("no-host", "the room is open but no host is attached to it"),
64
+ };
65
+ return { kind: "ignore" };
66
+ case "offer":
67
+ if (state.offered) return { kind: "duplicate-offer" };
68
+ return { kind: "offer", sdp: m.sdp, iceServers: m.iceServers || [], from: m.from };
69
+ case "candidate":
70
+ return { kind: "candidate", candidate: m.candidate, queue: !state.offered };
71
+ default:
72
+ return { kind: "ignore" };
73
+ }
74
+ }
75
+
76
+ /** The error for a socket that closed before the channel was confirmed. */
77
+ export function closeError(state: SignalState, code: number, reason: string): ConnectError {
78
+ const why = `signaling closed (code ${code}${reason ? `: ${reason}` : ""})`;
79
+ if (!state.opened) return new ConnectError("ws-not-open", `${why} before the socket opened`);
80
+ // 1008 is the server's policy-violation close: bad secret, room not open.
81
+ // Any close that carries a reason is the server explaining itself.
82
+ if (code === 1008 || reason) return new ConnectError("rejected", why);
83
+ if (!state.offered) return new ConnectError("no-host", `${why} before the host offered`);
84
+ return new ConnectError("ice-timeout", `${why} during the WebRTC handshake`);
85
+ }
86
+
87
+ /** The error for the connect deadline expiring. */
88
+ export function timeoutError(state: SignalState, timeoutMs: number): ConnectError {
89
+ const s = `${Math.round(timeoutMs / 1000)}s`;
90
+ if (!state.opened) return new ConnectError("ws-not-open", `signaling did not open within ${s}`);
91
+ if (!state.offered) return new ConnectError("no-host", `no host answered the join within ${s}`);
92
+ return new ConnectError("ice-timeout", `no WebRTC data channel came up within ${s}`);
93
+ }
94
+
95
+ /** The error for a WebSocket `error` event. */
96
+ export function socketError(state: SignalState, detail: string): ConnectError {
97
+ return state.opened
98
+ ? new ConnectError("rejected", `signaling error after connect: ${detail}`)
99
+ : new ConnectError("ws-not-open", `signaling error: ${detail}`);
100
+ }
101
+
102
+ /**
103
+ * The operator-facing explanation. `sandboxHint` is the HTTPS-probe-derived
104
+ * note from signalingHint.ts and is consulted ONLY for a socket that never
105
+ * opened: every later phase proves the WebSocket upgrade worked, so blaming a
106
+ * sandbox or proxy there would be wrong — and sent one reader down exactly
107
+ * that path.
108
+ */
109
+ export function describeConnectFailure(
110
+ err: Error,
111
+ host: string,
112
+ sandboxHint: (cause: string) => Promise<string>,
113
+ ): Promise<string> {
114
+ const phase = err instanceof ConnectError ? err.phase : "ws-not-open";
115
+ const cause = err.message;
116
+ switch (phase) {
117
+ case "no-host":
118
+ return Promise.resolve(
119
+ `no host in the room at ${host}: ${cause}\n` +
120
+ ` The signaling server is reachable and accepted the join — the daemon on the\n` +
121
+ ` other machine is not attached to this room. There, run \`ay share status\`:\n` +
122
+ ` if it prints a different link, the room rotated (or the daemon was\n` +
123
+ ` reinstalled) and this saved remote is stale — re-add it with the new link.`,
124
+ );
125
+ case "rejected":
126
+ return Promise.resolve(
127
+ `join rejected by the signaling host ${host}: ${cause}\n` +
128
+ ` The room id or secret in this saved remote no longer matches what the host\n` +
129
+ ` serves (it rotated, or the link was copied from a different machine).\n` +
130
+ ` On the host run \`ay share status\` and re-add the remote with that link.`,
131
+ );
132
+ case "ice-timeout":
133
+ return Promise.resolve(
134
+ `WebRTC handshake with the host behind ${host} failed: ${cause}\n` +
135
+ ` Signaling worked and the host answered, so this is not a sandbox or proxy\n` +
136
+ ` problem — the two machines could not open a data channel. Usual causes: a\n` +
137
+ ` second host answering in the same room (on the host: \`ay serve status\`,\n` +
138
+ ` and kill any stray \`ayrs serve\`), or ICE blocked between the networks.`,
139
+ );
140
+ default:
141
+ return sandboxHint(cause);
142
+ }
143
+ }
@@ -1,4 +0,0 @@
1
- import "./versionChecker-CCGSbjio.js";
2
- import { a as findSpawnHiddenLauncher, c as getBinDir, d as getRustBinary, f as hasValidMacSignature, i as findRustBinary, l as getBinaryName, n as downloadBinary, o as findTrayLauncher, r as findAyrsBinary, s as gcOldBinaryDirs, t as devBuildInfo, u as getDownloadUrl } from "./rustBinary-gI_r3g9K.js";
3
-
4
- export { findAyrsBinary, gcOldBinaryDirs };
@@ -1,4 +0,0 @@
1
- import "./e2e-z1xpwSAg.js";
2
- import { a as shareLinkFromRoomUrl, i as loadOrCreateShareRoom, n as getIceServers, o as startShare, r as importRTC, t as formatRoomLink } from "./share-C9oGvjXT.js";
3
-
4
- export { getIceServers, importRTC, loadOrCreateShareRoom, shareLinkFromRoomUrl, startShare };
@@ -1,5 +0,0 @@
1
- import "./versionChecker-CCGSbjio.js";
2
- import "./rustBinary-gI_r3g9K.js";
3
- import { a as trayHiddenMarker, i as launchTray, n as hasDesktop, r as isTrayHidden, t as cmdTray } from "./trayApp-PJrwfyDs.js";
4
-
5
- export { cmdTray };