@bivy/bivy 0.0.0 → 0.1.0-staging.10

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 (147) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4215 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/port-picker.mjs +40 -0
  9. package/bin/prune-sessions.mjs +52 -0
  10. package/bin/sessions-list.mjs +27 -0
  11. package/bin/shim-path.mjs +126 -0
  12. package/bin/uninstall-paths.mjs +48 -0
  13. package/dist/approval.js +87 -0
  14. package/dist/attach.js +248 -0
  15. package/dist/auth.js +258 -0
  16. package/dist/bivy-login.js +180 -0
  17. package/dist/browser-open.js +50 -0
  18. package/dist/control-plane-tasks.js +236 -0
  19. package/dist/data-dir.js +25 -0
  20. package/dist/device-registry.js +201 -0
  21. package/dist/e2e.js +70 -0
  22. package/dist/ephemeral-exec.js +109 -0
  23. package/dist/exec.js +209 -0
  24. package/dist/git-auth.js +155 -0
  25. package/dist/github-app-auth.js +107 -0
  26. package/dist/github-app-connect.js +235 -0
  27. package/dist/github-app-manifest.js +82 -0
  28. package/dist/github-app-sync-cli.js +93 -0
  29. package/dist/github-app-vault.js +106 -0
  30. package/dist/github-apps.js +121 -0
  31. package/dist/github-connect-repo.js +74 -0
  32. package/dist/github-device-auth.js +109 -0
  33. package/dist/github-tasks.js +650 -0
  34. package/dist/guard.js +109 -0
  35. package/dist/harness/cache-evict.js +88 -0
  36. package/dist/harness/checkpoint.js +0 -0
  37. package/dist/harness/cow-clone.js +84 -0
  38. package/dist/harness/dep-cache.js +78 -0
  39. package/dist/harness/disk-admission.js +46 -0
  40. package/dist/harness/egress.js +30 -0
  41. package/dist/harness/manager.js +97 -0
  42. package/dist/harness/mcp-config-formats.js +164 -0
  43. package/dist/harness/mcp-config.js +111 -0
  44. package/dist/harness/mcp-inject.js +134 -0
  45. package/dist/harness/mcp-proxy-cli.js +88 -0
  46. package/dist/harness/mcp-proxy.js +150 -0
  47. package/dist/harness/net-proxy.js +120 -0
  48. package/dist/harness/sandbox.js +96 -0
  49. package/dist/history-sync.js +26 -0
  50. package/dist/hosted-endpoints.d.mts +14 -0
  51. package/dist/hosted-endpoints.mjs +35 -0
  52. package/dist/identity.js +153 -0
  53. package/dist/integrations/index.js +4 -0
  54. package/dist/integrations/manager.js +279 -0
  55. package/dist/integrations/oauth.js +78 -0
  56. package/dist/integrations/registry.js +239 -0
  57. package/dist/integrations/store.js +54 -0
  58. package/dist/integrations/types.js +1 -0
  59. package/dist/linear-tasks.js +49 -0
  60. package/dist/metadata.js +226 -0
  61. package/dist/multiplexer.js +79 -0
  62. package/dist/native-pi.js +38 -0
  63. package/dist/node-stats.js +237 -0
  64. package/dist/pairing-crypto.js +105 -0
  65. package/dist/policy/conditions.js +103 -0
  66. package/dist/policy/policy-engine.js +20 -0
  67. package/dist/policy/risk.js +18 -0
  68. package/dist/policy/ruleset.js +113 -0
  69. package/dist/policy/run-policy.js +108 -0
  70. package/dist/policy/session-reroute.js +96 -0
  71. package/dist/pty-runner.py +95 -0
  72. package/dist/question.js +146 -0
  73. package/dist/redact.js +97 -0
  74. package/dist/relay-attach.js +345 -0
  75. package/dist/relay-chunk.js +73 -0
  76. package/dist/relay-cli-crypto.js +70 -0
  77. package/dist/relay-client.js +372 -0
  78. package/dist/relay-setup.js +262 -0
  79. package/dist/repo-workspace.js +208 -0
  80. package/dist/runtime/adoption.js +45 -0
  81. package/dist/runtime/agent-service-bin.js +149 -0
  82. package/dist/runtime/agent-service.js +439 -0
  83. package/dist/runtime/ansi.js +27 -0
  84. package/dist/runtime/anthropic-preflight.js +80 -0
  85. package/dist/runtime/claude-code.js +1408 -0
  86. package/dist/runtime/cli-parsers.js +647 -0
  87. package/dist/runtime/codex-auth.js +168 -0
  88. package/dist/runtime/codex-preflight.js +60 -0
  89. package/dist/runtime/codex-sessions.js +279 -0
  90. package/dist/runtime/control-plane-location.js +74 -0
  91. package/dist/runtime/credential-ingest.js +122 -0
  92. package/dist/runtime/credential-provisioning.js +79 -0
  93. package/dist/runtime/credential-store.js +435 -0
  94. package/dist/runtime/credentials.js +153 -0
  95. package/dist/runtime/host.js +153 -0
  96. package/dist/runtime/index.js +1552 -0
  97. package/dist/runtime/local-model-store.js +194 -0
  98. package/dist/runtime/location-registry.js +28 -0
  99. package/dist/runtime/model-catalog.js +97 -0
  100. package/dist/runtime/model-namer.js +85 -0
  101. package/dist/runtime/native-process-scan.js +102 -0
  102. package/dist/runtime/native-session-discovery.js +103 -0
  103. package/dist/runtime/normalize.js +75 -0
  104. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  105. package/dist/runtime/oauth/model-oauth.js +324 -0
  106. package/dist/runtime/opencode-preflight.js +55 -0
  107. package/dist/runtime/pi-auth.js +82 -0
  108. package/dist/runtime/pi-oauth.js +52 -0
  109. package/dist/runtime/pi-session-discovery.js +42 -0
  110. package/dist/runtime/pi.js +539 -0
  111. package/dist/runtime/process.js +499 -0
  112. package/dist/runtime/protocol.js +646 -0
  113. package/dist/runtime/remote.js +541 -0
  114. package/dist/runtime/rpc-protocol.js +56 -0
  115. package/dist/runtime/ruleset-store.js +117 -0
  116. package/dist/runtime/session-location.js +50 -0
  117. package/dist/runtime/types.js +17 -0
  118. package/dist/secrets-cli.js +134 -0
  119. package/dist/secrets.js +264 -0
  120. package/dist/server.js +9435 -0
  121. package/dist/session/bivy-session.js +1 -0
  122. package/dist/session/checkpoint-pack.js +133 -0
  123. package/dist/session/event-log.js +340 -0
  124. package/dist/session/fork-dirty.js +73 -0
  125. package/dist/session/fork-prereqs.js +61 -0
  126. package/dist/session/fork.js +90 -0
  127. package/dist/session/native-import.js +56 -0
  128. package/dist/session/reconnect.js +168 -0
  129. package/dist/session/replication-service.js +236 -0
  130. package/dist/session/replication.js +106 -0
  131. package/dist/session/replicator.js +140 -0
  132. package/dist/session/session-new-dedupe.js +42 -0
  133. package/dist/session/sibling-client.js +201 -0
  134. package/dist/session/transcript-merge.js +131 -0
  135. package/dist/session/transcript-normal.js +195 -0
  136. package/dist/session/workspace-context.js +1 -0
  137. package/dist/session-event-coalescer.js +50 -0
  138. package/dist/session-identity.js +34 -0
  139. package/dist/session-ref.js +65 -0
  140. package/dist/stt-cli.js +131 -0
  141. package/dist/stt.js +168 -0
  142. package/dist/terminal.js +409 -0
  143. package/dist/wire-format.js +67 -0
  144. package/dist/worktree-provision.js +118 -0
  145. package/dist/worktree.js +117 -0
  146. package/package.json +40 -6
  147. package/public/qr.js +464 -0
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Client half of the Bivy relay E2E handshake, for a CLI that reaches a node
4
+ // through the hosted relay (the `bivy run --node <account-node>` path).
5
+ //
6
+ // This is the SECURITY-CRITICAL core of relay tunnelling, deliberately isolated
7
+ // from the (network) transport so it can be unit-tested in-process against the
8
+ // real node-side PairingStore (see test/relay-cli-crypto.test.ts). It mirrors the
9
+ // browser client's crypto (packages/core transport-relay) using the same primitives
10
+ // the node uses, so a CLI device pairs and exchanges frames exactly like a phone.
11
+ //
12
+ // Flow (transport carries these opaque):
13
+ // 1. device keypair = newDeviceKeypair()
14
+ // 2. hello = buildHello(pairSecret, keypair) → send as t:"pair"
15
+ // 3. node.handleHello(hello) → welcome (node side)
16
+ // 4. roomKey = acceptWelcome(keypair, welcome) ← recover room key
17
+ // 5. sealFrame(roomKey, msg) / openFrame(roomKey, payload) ↔ bulk frames
18
+ //
19
+ // The pairSecret comes OUT OF BAND (a `bivy link`-style QR/token from the target
20
+ // node); the relay never sees it, so it can route frames but never derive the
21
+ // room key. See docs/relay-node-cli.md for the full transport design.
22
+ import { generatePairingKeypair, pairingProof, deriveWrapKey, unwrapRoomKey, } from "./pairing-crypto.js";
23
+ import { sealFrame, openFrame } from "./e2e.js";
24
+ /** A fresh device identity for this CLI (persist privateKeyB64 to reuse a pairing). */
25
+ export function newDeviceKeypair() {
26
+ return generatePairingKeypair();
27
+ }
28
+ /**
29
+ * Build the `pair.hello` payload proving knowledge of the out-of-band pairing
30
+ * secret without ever sending it. `label` shows up in the node's device list.
31
+ */
32
+ export function buildHello(pairSecretB64, keypair, label = "Bivy CLI") {
33
+ return {
34
+ k: "pair.hello",
35
+ devicePublicKeyB64: keypair.publicKeyB64,
36
+ proofB64: pairingProof(pairSecretB64, keypair.publicKeyB64),
37
+ label,
38
+ };
39
+ }
40
+ /**
41
+ * Recover the shared room key from the node's `pair.welcome`. Derives the same
42
+ * ECDH wrap key the node used (ECDH is symmetric) and unwraps the room key.
43
+ * Throws if the welcome is malformed or the wrap doesn't open (wrong node/key).
44
+ */
45
+ export function acceptWelcome(keypair, welcome) {
46
+ if (!welcome?.nodePublicKeyB64 || !welcome?.wrapped)
47
+ throw new Error("Malformed pair.welcome");
48
+ const wrapKey = deriveWrapKey(keypair.privateKeyB64, welcome.nodePublicKeyB64, "pair");
49
+ return unwrapRoomKey(wrapKey, welcome.wrapped);
50
+ }
51
+ /**
52
+ * A tiny stateful helper wrapping the room key for a session: seal outbound
53
+ * client messages and open inbound node frames. Transport-agnostic — feed it the
54
+ * relay's decrypted frame payloads and send its sealed output over the relay.
55
+ */
56
+ export class RoomCipher {
57
+ roomKey;
58
+ constructor(roomKey) {
59
+ this.roomKey = roomKey;
60
+ }
61
+ seal(message) {
62
+ return sealFrame(this.roomKey, message);
63
+ }
64
+ open(payload) {
65
+ return openFrame(this.roomKey, payload);
66
+ }
67
+ key() {
68
+ return this.roomKey;
69
+ }
70
+ }
@@ -0,0 +1,372 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { WebSocket } from "ws";
6
+ import { seal, sealFrame, openFrame, ReplayGuard } from "./e2e.js";
7
+ import { frameMessages, FrameReassembler } from "./relay-chunk.js";
8
+ function isFatalRelayError(message) {
9
+ return /hosted relay is not enabled|unauthorized|missing token/i.test(message);
10
+ }
11
+ export function loadRelayConfig(appDir) {
12
+ const envUrl = process.env.BIVY_RELAY_URL;
13
+ const filePath = path.join(appDir, "relay.json");
14
+ let raw = {};
15
+ if (fs.existsSync(filePath)) {
16
+ try {
17
+ raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
18
+ }
19
+ catch {
20
+ raw = {};
21
+ }
22
+ }
23
+ const url = envUrl ?? raw.url;
24
+ const enrollmentToken = process.env.BIVY_RELAY_TOKEN ?? raw.enrollmentToken;
25
+ if (!url || !enrollmentToken)
26
+ return null;
27
+ const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL ?? raw.controlPlaneUrl;
28
+ const clientBaseUrl = process.env.BIVY_CLIENT_BASE_URL ?? raw.clientBaseUrl ?? controlPlaneUrl;
29
+ return { url, enrollmentToken, controlPlaneUrl, clientBaseUrl };
30
+ }
31
+ // Application-level keepalive. On flaky/mobile links the node→relay TCP socket
32
+ // can half-open silently: the node still thinks it is connected and buffers
33
+ // sends into a dead socket while clients see "node offline". The relay's own
34
+ // heartbeat eventually reaps the dead socket, but on a lenient interval (up to a
35
+ // few minutes). Pinging from the node and reconnecting when pongs stop cuts that
36
+ // detection window to ~1 minute so the node redials (and clients recover) fast.
37
+ const HEARTBEAT_INTERVAL_MS = 30_000;
38
+ const HEARTBEAT_TIMEOUT_MS = 75_000;
39
+ // Only reset reconnect backoff after a connection has held this long. The relay
40
+ // accepts the WS upgrade and sends `ready` BEFORE its rate limiter may reject
41
+ // and close the socket a moment later, so resetting on `ready` alone let a
42
+ // rate-limited node reconnect every ~1s indefinitely — a storm that itself keeps
43
+ // tripping the limiter. Requiring a stable window lets backoff escalate normally.
44
+ const BACKOFF_STABLE_RESET_MS = 30_000;
45
+ export class RelayConnector {
46
+ config;
47
+ onClientMessage;
48
+ ws;
49
+ closed = false;
50
+ backoff = 1000;
51
+ heartbeatTimer;
52
+ stableTimer;
53
+ lastPongAt = 0;
54
+ // True only between the relay's `ready` message (auth + entitlement passed)
55
+ // and the socket closing. This — not "a connector object exists" — is what
56
+ // "connected" means to the control plane, so it's what `bivy status` reports.
57
+ ready = false;
58
+ // Most recent relay-side failure (ticket mint, socket error, or an `error`
59
+ // frame), surfaced by `bivy status`/`doctor` so a node that never connects
60
+ // explains why instead of silently showing "configured".
61
+ lastErrorMessage;
62
+ replay = new ReplayGuard();
63
+ reassembler = new FrameReassembler();
64
+ pairing;
65
+ onWorkAvailable;
66
+ constructor(config, onClientMessage, options) {
67
+ this.config = config;
68
+ this.onClientMessage = onClientMessage;
69
+ this.pairing = options.pairing;
70
+ this.onWorkAvailable = options.onWorkAvailable;
71
+ }
72
+ /** Current bulk-encryption key: the pairing store's rotating room key. */
73
+ roomKey() {
74
+ return this.pairing.roomKey();
75
+ }
76
+ /** Push a room-key rotation (after a device revoke) to connected devices. */
77
+ pushRotate(deliveries) {
78
+ if (this.ws?.readyState !== WebSocket.OPEN || deliveries.length === 0)
79
+ return;
80
+ this.ws.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "key.rotate", deliveries }) }));
81
+ }
82
+ /**
83
+ * True only while the relay link is live AND the relay has sent `ready`
84
+ * (auth/entitlement checks passed) — i.e. the node is actually reachable from
85
+ * the control plane. A socket that opened but was rejected, or one still
86
+ * reconnecting, reads false.
87
+ */
88
+ get connected() {
89
+ return this.ready && this.ws?.readyState === WebSocket.OPEN;
90
+ }
91
+ /** Most recent relay-side failure, if any — for status/diagnostics. */
92
+ get lastError() {
93
+ return this.lastErrorMessage;
94
+ }
95
+ start() {
96
+ this.closed = false;
97
+ void this.connect();
98
+ }
99
+ stop() {
100
+ this.closed = true;
101
+ this.ready = false;
102
+ this.stopHeartbeat();
103
+ this.clearBackoffReset();
104
+ this.ws?.close();
105
+ }
106
+ startHeartbeat(ws) {
107
+ this.stopHeartbeat();
108
+ this.lastPongAt = Date.now();
109
+ this.heartbeatTimer = setInterval(() => {
110
+ if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
111
+ this.stopHeartbeat();
112
+ return;
113
+ }
114
+ if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) {
115
+ // No pong since the last few pings — treat the link as half-open and
116
+ // force a reconnect rather than streaming into a dead socket.
117
+ console.warn("[relay] keepalive timed out; reconnecting");
118
+ this.stopHeartbeat();
119
+ ws.terminate();
120
+ return;
121
+ }
122
+ try {
123
+ ws.ping();
124
+ }
125
+ catch {
126
+ // A failed ping means the socket is already gone; close will follow.
127
+ }
128
+ }, HEARTBEAT_INTERVAL_MS);
129
+ this.heartbeatTimer.unref?.();
130
+ }
131
+ stopHeartbeat() {
132
+ if (this.heartbeatTimer) {
133
+ clearInterval(this.heartbeatTimer);
134
+ this.heartbeatTimer = undefined;
135
+ }
136
+ }
137
+ // Reset reconnect backoff only once this connection has stayed up for a stable
138
+ // window (see BACKOFF_STABLE_RESET_MS). Armed on `ready`, disarmed on close, so
139
+ // a connection that the relay rate-limits and drops seconds later never counts
140
+ // as healthy and backoff keeps escalating instead of hot-looping.
141
+ scheduleBackoffReset(ws) {
142
+ this.clearBackoffReset();
143
+ this.stableTimer = setTimeout(() => {
144
+ if (!this.closed && this.ws === ws && ws.readyState === WebSocket.OPEN)
145
+ this.backoff = 1000;
146
+ }, BACKOFF_STABLE_RESET_MS);
147
+ this.stableTimer.unref?.();
148
+ }
149
+ clearBackoffReset() {
150
+ if (this.stableTimer) {
151
+ clearTimeout(this.stableTimer);
152
+ this.stableTimer = undefined;
153
+ }
154
+ }
155
+ /**
156
+ * Encrypt a string with the current room key for AT-REST storage off-node
157
+ * (e.g. a session title in the control-plane index). Clients holding the room
158
+ * key decrypt it; the control plane only ever sees ciphertext.
159
+ */
160
+ sealString(value) {
161
+ return seal(this.roomKey(), value);
162
+ }
163
+ /** Push a local session event to remote clients (encrypted). */
164
+ sendEvent(event) {
165
+ if (this.ws?.readyState !== WebSocket.OPEN)
166
+ return;
167
+ const payload = sealFrame(this.roomKey(), event);
168
+ // Large events (big file reads, long diffs, image attachments) are split
169
+ // into multiple relay frames so none exceeds the relay's max-frame limit;
170
+ // the client reassembles them before decrypting.
171
+ for (const msg of frameMessages(payload)) {
172
+ if (this.ws?.readyState !== WebSocket.OPEN)
173
+ return;
174
+ this.ws.send(msg);
175
+ }
176
+ }
177
+ /**
178
+ * Handle a pairing control frame from a device (X25519 handshake). These are
179
+ * forwarded verbatim by the relay (t === "pair") and never contain the room
180
+ * key in the clear. Only `pair.hello` is acted on; the node replies with
181
+ * `pair.welcome` carrying the ECDH-wrapped room key.
182
+ */
183
+ async handlePairFrame(payload) {
184
+ let msg;
185
+ try {
186
+ msg = JSON.parse(payload);
187
+ }
188
+ catch {
189
+ return;
190
+ }
191
+ let welcome = null;
192
+ let reason = "Pairing failed";
193
+ if (msg.k === "pair.hello" && msg.devicePublicKeyB64 && msg.proofB64) {
194
+ welcome = this.pairing.handleHello({
195
+ devicePublicKeyB64: msg.devicePublicKeyB64,
196
+ proofB64: msg.proofB64,
197
+ label: msg.label,
198
+ });
199
+ }
200
+ else if (msg.k === "pair.account" && msg.devicePublicKeyB64 && msg.sessionToken) {
201
+ const auth = await this.authorizeAccountPairing(msg.sessionToken, msg.devicePublicKeyB64, msg.label, msg.ephemeral === true);
202
+ if (auth.ok)
203
+ welcome = this.pairing.trustDevice({ devicePublicKeyB64: msg.devicePublicKeyB64, label: msg.label });
204
+ else
205
+ reason = auth.reason;
206
+ }
207
+ else {
208
+ return;
209
+ }
210
+ if (!welcome) {
211
+ this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.error", error: reason }) }));
212
+ return;
213
+ }
214
+ this.ws?.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "pair.welcome", ...welcome }) }));
215
+ }
216
+ /**
217
+ * Verify that an account-session client is allowed to link this node. Returns
218
+ * the control plane's concrete failure reason (e.g. "Device limit reached")
219
+ * so the node can relay it to the client instead of a bare "pairing rejected".
220
+ */
221
+ async authorizeAccountPairing(sessionToken, devicePublicKeyB64, label, ephemeral = false) {
222
+ if (!this.config.controlPlaneUrl)
223
+ return { ok: false, reason: "Node is not linked to a control plane." };
224
+ try {
225
+ const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/authorize-client`, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
228
+ body: JSON.stringify({ sessionToken, devicePublicKeyB64, label, ephemeral }),
229
+ // Node's fetch has no default timeout. Without this, a control plane that
230
+ // accepts the TCP connection but never responds would hang pairing (and,
231
+ // on the mintTicket path, the whole relay reconnect) indefinitely.
232
+ signal: AbortSignal.timeout(10_000),
233
+ });
234
+ const data = (await res.json().catch(() => ({})));
235
+ if (res.ok && data.ok === true)
236
+ return { ok: true };
237
+ return { ok: false, reason: data.error || `Pairing rejected (${res.status}).` };
238
+ }
239
+ catch (err) {
240
+ return { ok: false, reason: err?.message || "Could not reach the control plane to pair." };
241
+ }
242
+ }
243
+ /**
244
+ * Exchange the long-lived enrollment token for a short-lived, single-use
245
+ * relay ticket — directly against the control plane (over TLS). Only the
246
+ * ticket is presented to the relay, so the relay never sees a reusable
247
+ * credential. A fresh ticket is minted on every (re)connect.
248
+ */
249
+ async mintTicket() {
250
+ if (!this.config.controlPlaneUrl) {
251
+ throw new Error("relay.json missing controlPlaneUrl (required for relay tickets)");
252
+ }
253
+ const res = await fetch(`${this.config.controlPlaneUrl.replace(/\/$/, "")}/node/relay-ticket`, {
254
+ method: "POST",
255
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.config.enrollmentToken}` },
256
+ body: "{}",
257
+ // connect() awaits this on every (re)connect; a hung control plane must
258
+ // fail fast into scheduleReconnect() rather than wedge remote access.
259
+ signal: AbortSignal.timeout(10_000),
260
+ });
261
+ if (!res.ok)
262
+ throw new Error(`ticket request failed: ${res.status}`);
263
+ const data = (await res.json());
264
+ if (!data?.ticket)
265
+ throw new Error("control plane returned no ticket");
266
+ return { ticket: data.ticket, relayUrl: typeof data.relayUrl === "string" ? data.relayUrl : undefined };
267
+ }
268
+ async connect() {
269
+ let ticket;
270
+ let relayUrl;
271
+ try {
272
+ ({ ticket, relayUrl } = await this.mintTicket());
273
+ }
274
+ catch (error) {
275
+ this.lastErrorMessage = `ticket mint failed: ${error.message}`;
276
+ console.warn("[relay] could not mint relay ticket:", error.message);
277
+ this.scheduleReconnect();
278
+ return;
279
+ }
280
+ if (this.closed)
281
+ return;
282
+ // Connect to the shard the control plane assigned this node (docs/scaling.md),
283
+ // falling back to the statically configured relay for older control planes.
284
+ const relayBase = (relayUrl ?? this.config.url).replace(/\/$/, "");
285
+ const target = `${relayBase}/node?ticket=${encodeURIComponent(ticket)}`;
286
+ const ws = new WebSocket(target);
287
+ this.ws = ws;
288
+ ws.on("open", () => {
289
+ // The TCP/WebSocket handshake succeeded, but relay auth/entitlement
290
+ // checks happen after upgrade. Wait for the relay's `ready` message
291
+ // before declaring the connector usable or resetting reconnect backoff.
292
+ });
293
+ ws.on("message", (data) => {
294
+ let env;
295
+ try {
296
+ env = JSON.parse(data.toString());
297
+ }
298
+ catch {
299
+ return;
300
+ }
301
+ if (env.t === "ready") {
302
+ this.ready = true;
303
+ this.lastErrorMessage = undefined;
304
+ this.startHeartbeat(ws);
305
+ this.scheduleBackoffReset(ws);
306
+ console.log("[relay] connected");
307
+ return;
308
+ }
309
+ if (env.t === "pair" && typeof env.p === "string") {
310
+ void this.handlePairFrame(env.p);
311
+ return;
312
+ }
313
+ if (env.t === "work.available") {
314
+ const hint = env;
315
+ this.onWorkAvailable?.({ id: typeof hint.id === "string" ? hint.id : undefined, label: typeof hint.label === "string" ? hint.label : undefined });
316
+ return;
317
+ }
318
+ if (env.t === "frame" && typeof env.p === "string") {
319
+ // Reassemble chunked frames (large client uploads) before decrypting;
320
+ // returns null while more chunks are still in flight.
321
+ const payload = this.reassembler.accept(env);
322
+ if (payload === null)
323
+ return;
324
+ try {
325
+ const frame = openFrame(this.roomKey(), payload);
326
+ if (!this.replay.accept(frame)) {
327
+ console.warn("[relay] dropped stale or replayed client frame");
328
+ return;
329
+ }
330
+ this.onClientMessage(frame.data);
331
+ }
332
+ catch {
333
+ console.warn("[relay] failed to decrypt client frame");
334
+ }
335
+ return;
336
+ }
337
+ if (env.t === "error") {
338
+ const message = env.error || "Relay error";
339
+ this.lastErrorMessage = message;
340
+ console.warn("[relay] error:", message);
341
+ if (isFatalRelayError(message)) {
342
+ console.warn("[relay] disabling connector; fix relay setup/plan and restart the node dev server");
343
+ this.closed = true;
344
+ ws.close();
345
+ }
346
+ }
347
+ });
348
+ ws.on("pong", () => {
349
+ this.lastPongAt = Date.now();
350
+ });
351
+ ws.on("close", () => {
352
+ this.ready = false;
353
+ this.stopHeartbeat();
354
+ this.clearBackoffReset();
355
+ this.scheduleReconnect();
356
+ });
357
+ ws.on("error", (error) => {
358
+ this.lastErrorMessage = error.message;
359
+ console.warn("[relay] socket error:", error.message);
360
+ });
361
+ }
362
+ scheduleReconnect() {
363
+ if (this.closed)
364
+ return;
365
+ const wait = this.backoff;
366
+ this.backoff = Math.min(this.backoff * 2, 30_000);
367
+ setTimeout(() => {
368
+ if (!this.closed)
369
+ void this.connect();
370
+ }, wait);
371
+ }
372
+ }