skydive-cli 0.5.0-beta.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +61 -13
  3. package/dist/js/api-BFQ4PQDA.mjs +315 -0
  4. package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
  5. package/dist/js/bin.mjs +674 -307
  6. package/dist/js/{boot-BCClEpCM.mjs → boot-DD4T-61U.mjs} +4558 -930
  7. package/dist/js/chunk-BbwQpWto.mjs +33 -0
  8. package/dist/js/{client-DabRpc_T.mjs → client--k9cjfkX.mjs} +437 -39
  9. package/dist/js/{client-c4c5MmgN.mjs → client-Btq6bMzX.mjs} +108 -2
  10. package/dist/js/client-Ct0-JZSS.mjs +5 -0
  11. package/dist/js/daemon-CCgNLD0H.mjs +7 -0
  12. package/dist/js/{daemon-k2kVkJ8D.mjs → daemon-Do1jU2UF.mjs} +123 -43
  13. package/dist/js/daemon-client-C7nE-lLK.mjs +8 -0
  14. package/dist/js/{daemon-client-fxf1A25Z.mjs → daemon-client-Dvad009G.mjs} +1 -1
  15. package/dist/js/dist-CRtjM7ba.mjs +1750 -0
  16. package/dist/js/forward-C-f04uyE.mjs +208 -0
  17. package/dist/js/{profiler-DawY0V0Z.mjs → install-CtAVvERm.mjs} +545 -215
  18. package/dist/js/launcher.mjs +49 -0
  19. package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
  20. package/dist/js/{print-Wakr3GJd.mjs → print-Bx8qUC9U.mjs} +3 -3
  21. package/dist/js/{print-BpuyEfWX.mjs → print-D_UEjdSw.mjs} +257 -35
  22. package/dist/js/{print-share-CKLPmsg0.mjs → print-share-Cz0EO2RK.mjs} +9 -3
  23. package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
  24. package/dist/js/{raw-pty-DY4KelZW.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
  25. package/dist/js/{rest-I3imNduB.mjs → rest-B9__Zsuk.mjs} +144 -19
  26. package/dist/js/rest-Dc0EEok3.mjs +6 -0
  27. package/dist/js/tls-cert-BpCaD5AT.mjs +4 -0
  28. package/dist/js/tls-cert-Rua2oV7n.mjs +67 -0
  29. package/package.json +12 -4
  30. package/dist/js/api-DG5W6iwx.mjs +0 -131
  31. package/dist/js/client-BuU34IVE.mjs +0 -5
  32. package/dist/js/daemon-BxU59xie.mjs +0 -6
  33. package/dist/js/daemon-client-C2BvZgKO.mjs +0 -7
  34. package/dist/js/forward-18QoL5dO.mjs +0 -68
  35. package/dist/js/raw-pty-DmdUf4_w.mjs +0 -5
  36. package/dist/js/rest-D29qNkto.mjs +0 -6
  37. /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
  38. /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
  39. /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+
4
+ //#region \0rolldown/runtime.js
5
+ var __create = Object.create;
6
+ var __defProp = Object.defineProperty;
7
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
+ var __getOwnPropNames = Object.getOwnPropertyNames;
9
+ var __getProtoOf = Object.getPrototypeOf;
10
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
11
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
15
+ key = keys[i];
16
+ if (!__hasOwnProp.call(to, key) && key !== except) {
17
+ __defProp(to, key, {
18
+ get: ((k) => from[k]).bind(null, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ }
23
+ }
24
+ return to;
25
+ };
26
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
27
+ value: mod,
28
+ enumerable: true
29
+ }) : target, mod));
30
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
31
+
32
+ //#endregion
33
+ export { __require as n, __toESM as r, __commonJSMin as t };
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, n as fetchPortalDevices, r as findThisDevice } from "./api-DG5W6iwx.mjs";
2
+ import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, l as startTlsForward, n as fetchPortalDevices, r as findThisDevice } from "./api-BFQ4PQDA.mjs";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
- import { execFile, spawn } from "node:child_process";
5
+ import * as childProcess from "node:child_process";
6
+ import { spawn } from "node:child_process";
6
7
  import net from "node:net";
7
8
  import { WebSocket, createWebSocketStream } from "ws";
8
9
 
9
10
  //#region ../portal-daemon/src/machine.ts
11
+ const SCUTIL_TIMEOUT_MS = 2e3;
12
+ const SCUTIL_ATTEMPTS = 3;
10
13
  /**
11
14
  * Identity this machine registers under on the portal.
12
15
  *
@@ -15,38 +18,74 @@ import { WebSocket, createWebSocketStream } from "ws";
15
18
  * row, so a grant applies to the machine no matter which surface the user
16
19
  * shared from. The name must therefore match what the desktop app registers —
17
20
  * on macOS that is `scutil --get LocalHostName` (the Rust client's own
18
- * derivation, stabler than the network-assigned hostname), with the plain
19
- * hostname as the fallback for other platforms or a failed read.
21
+ * derivation, stabler than the network-assigned hostname).
22
+ *
23
+ * The machine name is a durable IDENTITY, not a fresh network reading: every
24
+ * grant hangs off the device row keyed by this name, so if it changes the
25
+ * device flips to a brand-new row with ZERO grants and every agent re-prompts
26
+ * across every open CLI. `os.hostname()` is NOT durable — macOS rewrites it
27
+ * from DHCP/DNS per network — so it must never be allowed to silently replace
28
+ * a name we already registered under. Resolution order:
29
+ *
30
+ * 1. `scutil --get LocalHostName` (retried) — the stable, correct source.
31
+ * 2. `previous` — the name we last successfully registered under, persisted
32
+ * by the daemon. Preferred over the volatile hostname so a transient
33
+ * `scutil` failure keeps our existing identity (and grants) intact.
34
+ * 3. `os.hostname()` — last resort for a machine that has NEVER resolved a
35
+ * name (first run, non-macOS, scutil permanently unavailable).
20
36
  *
21
37
  * Historically the CLI suffixed `-cli` to keep a device distinct from the
22
38
  * desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
23
39
  */
24
- async function resolveMachineIdentity() {
25
- const fallback = fallbackHost();
26
- if (process.platform !== "darwin") return {
27
- machineName: fallback,
28
- friendlyName: fallback
40
+ async function resolveMachineIdentity(previous) {
41
+ const persisted = previous?.trim() || null;
42
+ if (process.platform !== "darwin") {
43
+ const machineName = persisted ?? fallbackHost();
44
+ return {
45
+ machineName,
46
+ friendlyName: machineName,
47
+ machineNameSource: persisted ? "persisted" : "hostname-fallback"
48
+ };
49
+ }
50
+ const local = await scutilRead("LocalHostName");
51
+ if (local) return {
52
+ machineName: local,
53
+ friendlyName: await scutilRead("ComputerName") ?? local,
54
+ machineNameSource: "scutil"
55
+ };
56
+ if (persisted) return {
57
+ machineName: persisted,
58
+ friendlyName: persisted,
59
+ machineNameSource: "persisted"
29
60
  };
30
- const local = await scutilRead("LocalHostName") ?? fallback;
61
+ const fallback = fallbackHost();
31
62
  return {
32
- machineName: local,
33
- friendlyName: await scutilRead("ComputerName") ?? local
63
+ machineName: fallback,
64
+ friendlyName: fallback,
65
+ machineNameSource: "hostname-fallback"
34
66
  };
35
67
  }
36
68
  function fallbackHost() {
37
69
  return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
38
70
  }
39
71
  function scutilRead(key) {
40
- return new Promise((resolve) => {
41
- execFile("/usr/sbin/scutil", ["--get", key], { timeout: 2e3 }, (error, stdout) => {
42
- if (error) {
43
- resolve(null);
44
- return;
45
- }
46
- const value = stdout.trim();
47
- resolve(value.length > 0 ? value : null);
72
+ return attempt(0);
73
+ function attempt(n) {
74
+ return new Promise((resolve) => {
75
+ childProcess.execFile("/usr/sbin/scutil", ["--get", key], { timeout: SCUTIL_TIMEOUT_MS }, (error, stdout) => {
76
+ if (error) {
77
+ if (n + 1 < SCUTIL_ATTEMPTS) {
78
+ resolve(attempt(n + 1));
79
+ return;
80
+ }
81
+ resolve(null);
82
+ return;
83
+ }
84
+ const value = stdout.trim();
85
+ resolve(value.length > 0 ? value : null);
86
+ });
48
87
  });
49
- });
88
+ }
50
89
  }
51
90
  const INHERITED_ENV = [
52
91
  "HOME",
@@ -77,9 +116,10 @@ function buildEnv(extra) {
77
116
  /**
78
117
  * Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
79
118
  * desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
80
- * machine/label ride as query pairs (percent-encoded by URL).
119
+ * machine/label (plus the dial diagnostics) ride as query pairs
120
+ * (percent-encoded by URL).
81
121
  */
82
- function portalWsUrl(appUrl, machine, label) {
122
+ function portalWsUrl(appUrl, machine, label, dial) {
83
123
  const base = appUrl.replace(/\/+$/, "");
84
124
  let wsBase;
85
125
  if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
@@ -88,6 +128,9 @@ function portalWsUrl(appUrl, machine, label) {
88
128
  const url = new URL(`${wsBase}/api/v1/portal/desktop`);
89
129
  url.searchParams.set("machine", machine);
90
130
  url.searchParams.set("label", label);
131
+ if (dial.build) url.searchParams.set("build", dial.build);
132
+ if (dial.reason) url.searchParams.set("reason", dial.reason);
133
+ if (dial.downMs !== null) url.searchParams.set("down_ms", String(Math.max(0, Math.round(dial.downMs))));
91
134
  return url.toString();
92
135
  }
93
136
 
@@ -104,6 +147,43 @@ function errorMessage(err) {
104
147
  return err instanceof Error ? err.message : String(err);
105
148
  }
106
149
 
150
+ //#endregion
151
+ //#region ../portal-daemon/src/build-stamp.ts
152
+ /**
153
+ * This build's stamp: the unix commit time (seconds) of the source it was
154
+ * compiled from. Empty string when unstamped (a dev source run without the
155
+ * env override).
156
+ *
157
+ * Why a commit time and not a package version: the daemon ships inside two
158
+ * independently-versioned installers (the skydive CLI and the desktop app),
159
+ * so their semvers are not comparable — but both build from this repo, so
160
+ * the commit time of the built tree is one monotonic clock they share.
161
+ */
162
+ function portalDaemonBuild() {
163
+ return "1787785513";
164
+ }
165
+ /**
166
+ * Whether a client carrying `mine` should replace a running daemon carrying
167
+ * `theirs` (newest build wins):
168
+ *
169
+ * - an unstamped client never takes over — it can't prove it's newer;
170
+ * - a stamped client replaces an unstamped daemon — every stamped build
171
+ * postdates stamping, so the unstamped daemon is older by construction;
172
+ * - otherwise strictly greater wins; equal keeps the incumbent, so two
173
+ * identical builds never bounce the daemon between them.
174
+ */
175
+ function isNewerBuild(mine, theirs) {
176
+ const mineAt = parseStamp(mine);
177
+ if (mineAt === null) return false;
178
+ const theirsAt = parseStamp(theirs);
179
+ if (theirsAt === null) return true;
180
+ return mineAt > theirsAt;
181
+ }
182
+ function parseStamp(stamp) {
183
+ if (!/^[0-9]+$/.test(stamp)) return null;
184
+ return Number(stamp);
185
+ }
186
+
107
187
  //#endregion
108
188
  //#region ../portal-protocol/src/index.ts
109
189
  const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
@@ -198,10 +278,36 @@ function decodeFrame(frame) {
198
278
  */
199
279
  var ReverseListener = class {
200
280
  server = null;
281
+ tls = null;
201
282
  conns = /* @__PURE__ */ new Set();
202
283
  constructor(opts) {
203
284
  this.opts = opts;
204
285
  }
286
+ /**
287
+ * One tunnel to the sandbox target — the same per-connection edge dial the
288
+ * plain accept path inlines, shaped for the TLS listener's acquireTunnel
289
+ * seam. Resolves once the upgrade completes, with the duplex PAUSED so no
290
+ * early bytes drop before adoption.
291
+ */
292
+ dialTunnel() {
293
+ const { dialOrigin, targetPort, token } = this.opts.target();
294
+ const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
295
+ return new Promise((resolve, reject) => {
296
+ ws.once("error", reject);
297
+ ws.once("unexpected-response", (_req, res) => {
298
+ res.destroy();
299
+ reject(/* @__PURE__ */ new Error(`tunnel rejected (HTTP ${res.statusCode ?? "?"})`));
300
+ });
301
+ ws.once("open", () => {
302
+ const stream = createWebSocketStream(ws);
303
+ stream.pause();
304
+ resolve({
305
+ stream,
306
+ close: () => stream.destroy()
307
+ });
308
+ });
309
+ });
310
+ }
205
311
  start() {
206
312
  const server = net.createServer((sock) => {
207
313
  this.conns.add(sock);
@@ -227,12 +333,52 @@ var ReverseListener = class {
227
333
  });
228
334
  });
229
335
  server.once("error", (err) => this.opts.onListening(err));
230
- server.listen(this.opts.listenPort, "127.0.0.1", () => this.opts.onListening(null));
336
+ server.listen(this.opts.listenPort, "127.0.0.1", () => {
337
+ this.opts.onListening(null);
338
+ this.startTls();
339
+ });
231
340
  this.server = server;
232
341
  }
342
+ /**
343
+ * The additive TLS+h2 twin (fire-and-forget from start): browsers negotiate
344
+ * h2 there, lifting the 6-connection cap that serializes module-heavy dev
345
+ * servers. Every failure logs once and leaves the plain listener — the
346
+ * product's default behavior — untouched. The ack (`reverse_listening`)
347
+ * deliberately stays keyed to the plain bind: TLS is an enhancement, never
348
+ * a condition of "the tunnel is up".
349
+ */
350
+ startTls() {
351
+ const source = this.opts.tlsCertSource;
352
+ if (!source) return;
353
+ (async () => {
354
+ try {
355
+ const cert = await source();
356
+ if (!cert || this.server === null) return;
357
+ this.tls = await startTlsForward({
358
+ cert,
359
+ localPort: this.opts.listenPort,
360
+ targetPort: this.opts.target().targetPort,
361
+ acquireTunnel: () => this.dialTunnel(),
362
+ log: this.opts.log
363
+ });
364
+ if (this.server === null) {
365
+ const straggler = this.tls;
366
+ this.tls = null;
367
+ await straggler.close();
368
+ return;
369
+ }
370
+ this.opts.log(`expose: TLS origin up: https://${this.tls.hostname}:${this.tls.port} (HTTP/2)`);
371
+ } catch (error) {
372
+ this.opts.log(`expose: TLS origin unavailable (${error instanceof Error ? error.message : String(error)}); http listener unaffected`);
373
+ }
374
+ })();
375
+ }
233
376
  stop() {
234
377
  this.server?.close();
235
378
  this.server = null;
379
+ const tls = this.tls;
380
+ this.tls = null;
381
+ if (tls) tls.close();
236
382
  for (const sock of this.conns) sock.destroy();
237
383
  this.conns.clear();
238
384
  }
@@ -326,6 +472,7 @@ var JobManager = class {
326
472
  listener: new ReverseListener({
327
473
  listenPort,
328
474
  target: () => rev.target,
475
+ tlsCertSource: this.opts.tlsCertSource,
329
476
  log: (msg) => {
330
477
  if (rev.settled) return;
331
478
  this.opts.send(encodeData(id, STREAM.stderr, rev.seq, Buffer.from(msg)));
@@ -430,8 +577,71 @@ var JobManager = class {
430
577
 
431
578
  //#endregion
432
579
  //#region ../portal-daemon/src/client.ts
433
- const INITIAL_BACKOFF_MS = 500;
434
- const MAX_BACKOFF_MS = 1e4;
580
+ const DEFAULT_HEARTBEAT = {
581
+ intervalMs: 15e3,
582
+ maxMissed: 2,
583
+ wakeTickMs: 2e3,
584
+ wakeGapThresholdMs: 1e4,
585
+ netWatchTickMs: 2e3,
586
+ initialBackoffMs: 500,
587
+ maxBackoffMs: 1e4
588
+ };
589
+ /**
590
+ * A cheap fingerprint of the machine's network identity: every non-internal
591
+ * interface name, its IPv4 addresses, and the /64 prefixes of its routable
592
+ * IPv6 addresses. When it changes, the active network changed (interface
593
+ * came/went, DHCP handed out a new address, VPN tunnel up/down, v6-only
594
+ * network moved) and any live socket is riding a route that no longer exists.
595
+ *
596
+ * IPv6 is fingerprinted by PREFIX, not full address: macOS rotates temporary
597
+ * IPv6 addresses on a schedule (RFC 8981), which would read as a "change"
598
+ * every rotation and cause spurious reconnects — but every rotated address
599
+ * stays inside the same delegated /64, so the prefix is rotation-immune while
600
+ * still catching a v6-only network switch (a different network delegates a
601
+ * different prefix). Link-local (fe80::/10) is skipped outright: it exists on
602
+ * every interface regardless of which network is attached, so it carries no
603
+ * network identity the interface NAME doesn't already.
604
+ */
605
+ function networkFingerprint() {
606
+ const parts = [];
607
+ for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
608
+ let external = false;
609
+ const v6Prefixes = /* @__PURE__ */ new Set();
610
+ for (const addr of addrs ?? []) {
611
+ if (addr.internal) continue;
612
+ external = true;
613
+ if (addr.family === "IPv4") parts.push(`${name}/${addr.address}`);
614
+ if (addr.family === "IPv6") {
615
+ const prefix = ipv6Prefix64(addr.address);
616
+ if (prefix) v6Prefixes.add(`${name}/${prefix}`);
617
+ }
618
+ }
619
+ for (const prefix of v6Prefixes) parts.push(prefix);
620
+ if (external) parts.push(name);
621
+ }
622
+ return parts.sort().join("|");
623
+ }
624
+ /**
625
+ * The first 64 bits of an IPv6 address as a normalized `a:b:c:d` string, or
626
+ * null for link-local / unparsable input. See networkFingerprint for why the
627
+ * prefix (and not the full address) is the identity worth watching.
628
+ */
629
+ function ipv6Prefix64(address) {
630
+ const [addr] = address.split("%");
631
+ if (!addr) return null;
632
+ if (/^fe[89ab]/i.test(addr)) return null;
633
+ const halves = addr.split("::");
634
+ if (halves.length > 2) return null;
635
+ const head = halves[0] ? halves[0].split(":") : [];
636
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
637
+ const missing = 8 - head.length - tail.length;
638
+ if (missing < 0 || halves.length === 1 && missing !== 0) return null;
639
+ return [
640
+ ...head,
641
+ ...Array(missing).fill("0"),
642
+ ...tail
643
+ ].slice(0, 4).map((group) => group.padStart(4, "0").toLowerCase()).join(":");
644
+ }
435
645
  /**
436
646
  * Shares the local machine with agents over the portal: dials OUT to the api's
437
647
  * desktop-portal WebSocket (authenticating with a short-lived device token
@@ -454,8 +664,104 @@ var PortalClient = class {
454
664
  machineName = "";
455
665
  friendlyName = "";
456
666
  legacyGrantsChecked = false;
667
+ wakeWatch = null;
668
+ lastTick = Date.now();
669
+ netWatch = null;
670
+ netFingerprint = networkFingerprint();
671
+ backoffKick = null;
672
+ dialReason = null;
673
+ disconnectedAt = null;
674
+ handingOff = false;
675
+ handoffResolve = null;
676
+ heartbeatConfig;
457
677
  constructor(opts) {
458
678
  this.opts = opts;
679
+ this.heartbeatConfig = {
680
+ ...DEFAULT_HEARTBEAT,
681
+ ...opts.heartbeat
682
+ };
683
+ this.startWakeWatch();
684
+ this.startNetWatch();
685
+ }
686
+ /**
687
+ * Detect wake-from-sleep without an Electron/`powerMonitor` dependency (the
688
+ * daemon also runs headless under the CLI). A repeating timer that should
689
+ * fire every WAKE_TICK_MS; when the actual gap since the last tick far
690
+ * exceeds that, the process was suspended (macOS sleep, laptop lid) and the
691
+ * current socket — if any — is a zombie against a server that has already
692
+ * reaped our presence. Terminate it now so `connectLoop` redials at once
693
+ * instead of waiting out the heartbeat deadline. Cheap: one no-op timer.
694
+ */
695
+ startWakeWatch() {
696
+ this.lastTick = Date.now();
697
+ this.wakeWatch = setInterval(() => {
698
+ const now = Date.now();
699
+ const gap = now - this.lastTick;
700
+ this.lastTick = now;
701
+ if (gap > this.heartbeatConfig.wakeGapThresholdMs) this.forceReconnect("woke from sleep; reconnecting portal");
702
+ }, this.heartbeatConfig.wakeTickMs);
703
+ this.wakeWatch.unref?.();
704
+ }
705
+ /**
706
+ * Detect a network switch — the awake sibling of the wake detector. Changing
707
+ * Wi-Fi↔ethernet, switching SSID, or toggling a VPN kills the socket's route
708
+ * with no FIN/RST, so `ws` may never fire 'close' and the heartbeat's
709
+ * missed-pong deadline is the best case (~2 intervals), not a guarantee.
710
+ * Poll the network-identity fingerprint and, the moment it changes, terminate
711
+ * the socket so `connectLoop` redials over the new network immediately.
712
+ * Cheap: `os.networkInterfaces()` is one syscall-backed read per tick.
713
+ */
714
+ startNetWatch() {
715
+ this.netWatch = setInterval(() => {
716
+ const fingerprint = networkFingerprint();
717
+ if (fingerprint === this.netFingerprint) return;
718
+ this.netFingerprint = fingerprint;
719
+ this.forceReconnect("network changed; reconnecting portal");
720
+ }, this.heartbeatConfig.netWatchTickMs);
721
+ this.netWatch.unref?.();
722
+ }
723
+ /**
724
+ * The one reaction every detector (wake, network change) converges on: make
725
+ * the connect loop re-evaluate NOW, whatever state it is in. With a live
726
+ * socket, terminate it — 'close' resolves `runConnection` and the loop
727
+ * redials. Between dials (backing off after a failure, e.g. while the
728
+ * network was down), cut the sleep short so the retry fires immediately — a
729
+ * dial that was pointless a moment ago is exactly right the moment the
730
+ * network comes back. These are per-machine local events, uncorrelated
731
+ * across the fleet, so the immediate retry can't herd; the backoff VALUE is
732
+ * untouched and still governs the next failure.
733
+ */
734
+ forceReconnect(reason) {
735
+ this.dialReason = reason;
736
+ this.disconnectedAt ??= Date.now();
737
+ if (this.ws) {
738
+ this.error = reason;
739
+ this.ws.terminate();
740
+ return;
741
+ }
742
+ this.kickBackoff();
743
+ }
744
+ kickBackoff() {
745
+ const kick = this.backoffKick;
746
+ this.backoffKick = null;
747
+ kick?.();
748
+ }
749
+ /** The between-redials sleep, interruptible by a detector (forceReconnect). */
750
+ backoffSleep(ms) {
751
+ return new Promise((resolve) => {
752
+ let done = false;
753
+ const finish = () => {
754
+ if (done) return;
755
+ done = true;
756
+ this.backoffKick = null;
757
+ resolve();
758
+ };
759
+ const timer = setTimeout(finish, ms);
760
+ this.backoffKick = () => {
761
+ clearTimeout(timer);
762
+ finish();
763
+ };
764
+ });
459
765
  }
460
766
  isEnabled() {
461
767
  return this.enabled;
@@ -481,6 +787,7 @@ var PortalClient = class {
481
787
  this.ws = null;
482
788
  this.deviceId = null;
483
789
  this.granted = /* @__PURE__ */ new Set();
790
+ this.kickBackoff();
484
791
  this.setStatus("off");
485
792
  }
486
793
  /**
@@ -491,10 +798,61 @@ var PortalClient = class {
491
798
  dispose() {
492
799
  this.disposed = true;
493
800
  this.enabled = false;
801
+ if (this.wakeWatch) {
802
+ clearInterval(this.wakeWatch);
803
+ this.wakeWatch = null;
804
+ }
805
+ if (this.netWatch) {
806
+ clearInterval(this.netWatch);
807
+ this.netWatch = null;
808
+ }
494
809
  this.jobs?.killAll();
495
810
  this.jobs = null;
496
811
  this.ws?.close();
497
812
  this.ws = null;
813
+ this.kickBackoff();
814
+ this.finishHandoff();
815
+ }
816
+ /**
817
+ * Begin a make-before-break handoff to a successor daemon. Keep the current
818
+ * portal socket (and thus this machine's presence claim) alive so there is no
819
+ * `registered but not connected` gap, but stop redialing once it closes — the
820
+ * successor's `claim()` supersedes us and the server closes our socket, which
821
+ * is our cue to step down. Resolves when the socket has closed (clean
822
+ * handoff) OR after `timeoutMs` (the successor never came up, so we release
823
+ * the slot rather than hold it forever). Idempotent.
824
+ */
825
+ beginHandoff(timeoutMs) {
826
+ if (this.disposed) return Promise.resolve();
827
+ if (this.handingOff) return new Promise((resolve) => {
828
+ const prev = this.handoffResolve;
829
+ this.handoffResolve = () => {
830
+ prev?.();
831
+ resolve();
832
+ };
833
+ });
834
+ this.handingOff = true;
835
+ if (!this.ws) return Promise.resolve();
836
+ return new Promise((resolve) => {
837
+ let done = false;
838
+ const finish = () => {
839
+ if (done) return;
840
+ done = true;
841
+ clearTimeout(timer);
842
+ resolve();
843
+ };
844
+ this.handoffResolve = finish;
845
+ const timer = setTimeout(() => {
846
+ this.disable();
847
+ this.finishHandoff();
848
+ }, timeoutMs);
849
+ if (typeof timer.unref === "function") timer.unref();
850
+ });
851
+ }
852
+ finishHandoff() {
853
+ const resolve = this.handoffResolve;
854
+ this.handoffResolve = null;
855
+ resolve?.();
498
856
  }
499
857
  /**
500
858
  * Grant one agent access to this machine (default-deny; user-initiated).
@@ -550,55 +908,95 @@ var PortalClient = class {
550
908
  }
551
909
  async connectLoop() {
552
910
  if (!this.machineName) {
553
- const identity = await resolveMachineIdentity();
911
+ const identity = await resolveMachineIdentity(this.opts.persistedMachineName);
554
912
  this.machineName = identity.machineName;
555
913
  this.friendlyName = identity.friendlyName;
914
+ this.opts.onMachineName(identity.machineName, identity.machineNameSource);
556
915
  }
557
- let backoff = INITIAL_BACKOFF_MS;
558
- while (this.enabled && !this.disposed) {
916
+ let backoff = this.heartbeatConfig.initialBackoffMs;
917
+ while (this.enabled && !this.disposed && !this.handingOff) {
559
918
  this.setStatus("connecting");
560
919
  try {
561
920
  const token = await this.wsToken();
562
- await this.runConnection(token);
563
- backoff = INITIAL_BACKOFF_MS;
921
+ if (await this.runConnection(token)) backoff = this.heartbeatConfig.initialBackoffMs;
922
+ else backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
564
923
  } catch (err) {
565
- if (!this.enabled || this.disposed) break;
924
+ if (!this.enabled || this.disposed || this.handingOff) break;
566
925
  this.setStatus("error", errorMessage(err));
926
+ backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
567
927
  }
568
- if (!this.enabled || this.disposed) break;
569
- await sleep(backoff);
570
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
928
+ if (!this.enabled || this.disposed || this.handingOff) break;
929
+ await this.backoffSleep(Math.random() * backoff);
571
930
  }
572
931
  }
573
932
  runConnection(token) {
574
933
  return new Promise((resolve) => {
575
- const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
934
+ let settled = false;
935
+ let healthy = false;
936
+ const settle = () => {
937
+ if (settled) return;
938
+ settled = true;
939
+ resolve(healthy);
940
+ };
941
+ const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName, {
942
+ build: portalDaemonBuild(),
943
+ reason: this.dialReason ?? "initial",
944
+ downMs: this.disconnectedAt === null ? null : Date.now() - this.disconnectedAt
945
+ }), {
576
946
  headers: { authorization: `Bearer ${token}` },
577
947
  maxPayload: MAX_WS_FRAME_BYTES
578
948
  });
579
949
  this.ws = ws;
580
950
  const jobs = new JobManager({
581
951
  resolveCwd: this.opts.resolveCwd,
952
+ tlsCertSource: this.opts.tlsCertSource,
582
953
  send: (frame) => {
583
954
  if (ws.readyState === WebSocket.OPEN) ws.send(frame);
584
955
  }
585
956
  });
586
957
  this.jobs = jobs;
958
+ let missed = 0;
959
+ const heartbeat = setInterval(() => {
960
+ if (ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) return;
961
+ if (missed >= this.heartbeatConfig.maxMissed) {
962
+ this.error = ws.readyState === WebSocket.CONNECTING ? "portal handshake stalled; reconnecting" : "portal connection went silent (no pong); reconnecting";
963
+ this.dialReason = this.error;
964
+ this.disconnectedAt ??= Date.now();
965
+ ws.terminate();
966
+ return;
967
+ }
968
+ missed += 1;
969
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
970
+ }, this.heartbeatConfig.intervalMs);
971
+ const markAlive = () => {
972
+ missed = 0;
973
+ healthy = true;
974
+ };
587
975
  ws.on("open", () => {
976
+ missed = 0;
977
+ this.dialReason = null;
978
+ this.disconnectedAt = null;
588
979
  this.setStatus("connected");
589
980
  this.syncDeviceState();
590
981
  });
982
+ ws.on("pong", markAlive);
983
+ ws.on("ping", markAlive);
591
984
  ws.on("message", (data, isBinary) => {
985
+ markAlive();
592
986
  if (isBinary) jobs.handleFrame(toBuffer(data));
593
987
  });
594
988
  ws.on("error", (err) => {
595
989
  this.error = errorMessage(err);
596
990
  });
597
- ws.on("close", () => {
991
+ ws.on("close", (code) => {
992
+ this.dialReason ??= `connection closed (code ${code})`;
993
+ this.disconnectedAt ??= Date.now();
994
+ clearInterval(heartbeat);
598
995
  jobs.killAll();
599
996
  if (this.jobs === jobs) this.jobs = null;
600
997
  if (this.ws === ws) this.ws = null;
601
- resolve();
998
+ if (this.handingOff) this.finishHandoff();
999
+ settle();
602
1000
  });
603
1001
  });
604
1002
  }
@@ -650,4 +1048,4 @@ function sleep(ms) {
650
1048
  }
651
1049
 
652
1050
  //#endregion
653
- export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
1051
+ export { resolveMachineIdentity as a, isRecord as i, isNewerBuild as n, portalDaemonBuild as r, PortalClient as t };