skydive-cli 0.6.0-beta.24 → 0.6.0-beta.241

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.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { t as HttpError } from "./http-error-BF2NZZE3.mjs";
2
3
  import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, l as startTlsForward, n as fetchPortalDevices, r as findThisDevice } from "./api-BFQ4PQDA.mjs";
3
4
  import os from "node:os";
4
5
  import { z } from "zod";
@@ -160,7 +161,7 @@ function errorMessage(err) {
160
161
  * the commit time of the built tree is one monotonic clock they share.
161
162
  */
162
163
  function portalDaemonBuild() {
163
- return "1789577256";
164
+ return "1794120070";
164
165
  }
165
166
  /**
166
167
  * Whether a client carrying `mine` should replace a running daemon carrying
@@ -579,18 +580,41 @@ var JobManager = class {
579
580
 
580
581
  //#endregion
581
582
  //#region ../portal-daemon/src/client.ts
583
+ const INITIAL_BACKOFF_MS = 500;
584
+ const MAX_BACKOFF_MS = 1e4;
585
+ const AUTH_STAND_DOWN_MS = 5 * 6e4;
586
+ const AUTH_STAND_DOWN_MESSAGE = "portal sign-in expired — reopen Skydive Desktop (or run `skydive portal open`) to reconnect";
587
+ /**
588
+ * True when the error means the server could not authenticate us: a token-mint
589
+ * failure (HttpError 401) or a WS upgrade rejected at the auth layer. `ws`
590
+ * reports a rejected upgrade as `Unexpected server response: <status>` with no
591
+ * structured status property, so the status is read from the message. A 429 is
592
+ * deliberately NOT an auth failure — see isRateLimited.
593
+ */
594
+ function isAuthFailure(err) {
595
+ if (err instanceof HttpError) return err.status === 401;
596
+ return /Unexpected server response: 401\b/.test(errorMessage(err));
597
+ }
598
+ const RATE_LIMIT_STAND_DOWN_MS = 12e4;
599
+ const RATE_LIMIT_STAND_DOWN_MESSAGE = "portal connection rate limited — retrying in 2 minutes";
600
+ function isRateLimited(err) {
601
+ if (err instanceof HttpError) return err.status === 429;
602
+ return /Unexpected server response: 429\b/.test(errorMessage(err));
603
+ }
582
604
  const DEFAULT_HEARTBEAT = {
583
605
  intervalMs: 15e3,
584
606
  maxMissed: 2,
585
607
  wakeTickMs: 2e3,
586
608
  wakeGapThresholdMs: 1e4,
587
609
  netWatchTickMs: 2e3,
588
- initialBackoffMs: 500,
589
- maxBackoffMs: 1e4,
610
+ initialBackoffMs: INITIAL_BACKOFF_MS,
611
+ maxBackoffMs: MAX_BACKOFF_MS,
590
612
  supersededBackoffFloorMs: 5e3,
591
613
  flapWindowMs: 6e4,
592
614
  flapMaxStandDowns: 3,
593
- flapBackoffMs: 6e4
615
+ flapBackoffMs: 6e4,
616
+ authStandDownMs: AUTH_STAND_DOWN_MS,
617
+ rateLimitStandDownMs: RATE_LIMIT_STAND_DOWN_MS
594
618
  };
595
619
  /**
596
620
  * A cheap fingerprint of the machine's network identity: every non-internal
@@ -670,6 +694,8 @@ var PortalClient = class {
670
694
  machineName = "";
671
695
  friendlyName = "";
672
696
  legacyGrantsChecked = false;
697
+ authRejected = false;
698
+ rateLimited = false;
673
699
  wakeWatch = null;
674
700
  lastTick = Date.now();
675
701
  netWatch = null;
@@ -785,6 +811,8 @@ var PortalClient = class {
785
811
  if (this.enabled || this.disposed) return;
786
812
  this.enabled = true;
787
813
  this.error = null;
814
+ this.authRejected = false;
815
+ this.rateLimited = false;
788
816
  this.connectLoop();
789
817
  }
790
818
  disable() {
@@ -930,6 +958,7 @@ var PortalClient = class {
930
958
  const token = await this.wsToken();
931
959
  const { healthy, standDown } = await this.runConnection(token);
932
960
  if (standDown) {
961
+ this.setStatus("stand-down");
933
962
  const now = Date.now();
934
963
  standDowns.push(now);
935
964
  standDowns = standDowns.filter((at) => now - at <= this.heartbeatConfig.flapWindowMs);
@@ -939,11 +968,31 @@ var PortalClient = class {
939
968
  } else backoff = Math.max(Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs), this.heartbeatConfig.supersededBackoffFloorMs);
940
969
  sleepFloor = backoff / 2;
941
970
  } else if (healthy) backoff = this.heartbeatConfig.initialBackoffMs;
942
- else backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
971
+ else if (this.authRejected) {
972
+ this.setStatus("error", AUTH_STAND_DOWN_MESSAGE);
973
+ this.authRejected = false;
974
+ backoff = this.heartbeatConfig.authStandDownMs;
975
+ sleepFloor = backoff;
976
+ } else if (this.rateLimited) {
977
+ this.setStatus("error", RATE_LIMIT_STAND_DOWN_MESSAGE);
978
+ this.rateLimited = false;
979
+ backoff = this.heartbeatConfig.rateLimitStandDownMs;
980
+ sleepFloor = backoff;
981
+ } else backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
943
982
  } catch (err) {
944
983
  if (!this.enabled || this.disposed || this.handingOff) break;
945
- this.setStatus("error", errorMessage(err));
946
- backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
984
+ if (isAuthFailure(err)) {
985
+ this.setStatus("error", AUTH_STAND_DOWN_MESSAGE);
986
+ backoff = this.heartbeatConfig.authStandDownMs;
987
+ sleepFloor = backoff;
988
+ } else if (isRateLimited(err)) {
989
+ this.setStatus("error", RATE_LIMIT_STAND_DOWN_MESSAGE);
990
+ backoff = this.heartbeatConfig.rateLimitStandDownMs;
991
+ sleepFloor = backoff;
992
+ } else {
993
+ this.setStatus("error", errorMessage(err));
994
+ backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
995
+ }
947
996
  }
948
997
  if (!this.enabled || this.disposed || this.handingOff) break;
949
998
  this.inStandDownSleep = sleepFloor > 0;
@@ -1000,6 +1049,8 @@ var PortalClient = class {
1000
1049
  };
1001
1050
  ws.on("open", () => {
1002
1051
  missed = 0;
1052
+ this.authRejected = false;
1053
+ this.rateLimited = false;
1003
1054
  this.dialReason = null;
1004
1055
  this.disconnectedAt = null;
1005
1056
  this.setStatus("connected");
@@ -1013,6 +1064,14 @@ var PortalClient = class {
1013
1064
  });
1014
1065
  ws.on("error", (err) => {
1015
1066
  this.error = errorMessage(err);
1067
+ if (isAuthFailure(err)) {
1068
+ this.authRejected = true;
1069
+ this.dialReason ??= "portal credentials rejected; standing down";
1070
+ }
1071
+ if (isRateLimited(err)) {
1072
+ this.rateLimited = true;
1073
+ this.dialReason ??= "portal connection rate limited; standing down";
1074
+ }
1016
1075
  });
1017
1076
  ws.on("close", (code, reason) => {
1018
1077
  standDown = code === CLOSE_SUPERSEDED || code === CLOSE_FLAP_COOLDOWN || code === 1e3 && reason.toString() === "superseded";
@@ -1076,4 +1135,4 @@ function sleep(ms) {
1076
1135
  }
1077
1136
 
1078
1137
  //#endregion
1079
- export { resolveMachineIdentity as a, isRecord as i, isNewerBuild as n, portalDaemonBuild as r, PortalClient as t };
1138
+ export { portalDaemonBuild as a, isNewerBuild as i, PortalClient as n, isRecord as o, RATE_LIMIT_STAND_DOWN_MS as r, resolveMachineIdentity as s, AUTH_STAND_DOWN_MS as t };
@@ -91,7 +91,7 @@ function parseFrame(expectedType, frameBytes) {
91
91
  const parsed = jsonLen ? JSON.parse(textDecoder.decode(frameBytes.subarray(jsonStart, jsonEnd))) : {};
92
92
  if (typeof parsed !== "object" || parsed === null) return null;
93
93
  json = parsed;
94
- } catch {
94
+ } catch (_error) {
95
95
  return null;
96
96
  }
97
97
  return {
@@ -113,6 +113,18 @@ const FRAME = {
113
113
  RESIZE: 17
114
114
  };
115
115
  const MAX_INPUT_BYTES = 1 * 1024 * 1024;
116
+ /** Upper bound for an exec's `timeoutMs`; larger asks are clamped to this. */
117
+ const MAX_EXEC_TIMEOUT_MS = 1440 * 60 * 1e3;
118
+ /**
119
+ * The exec budget applied when the client sends no `timeoutMs` of its own.
120
+ * Liberal by design — one-shot execs legitimately run installs, builds, and
121
+ * long data pulls — but not absent: without it, the e2b SDK's own default
122
+ * (60s) silently kills anything longer mid-run with an opaque
123
+ * `deadline_exceeded`, which is far below what the CLI's `--timeout` help
124
+ * promises ("the server default (~10 min) applies"). Callers with a bigger
125
+ * budget send an explicit `timeoutMs` (clamped to {@link MAX_EXEC_TIMEOUT_MS}).
126
+ */
127
+ const DEFAULT_EXEC_TIMEOUT_MS = 600 * 1e3;
116
128
  /** Query params for the upgrade URL, from a spec. Inverse of {@link parseStreamSpec}. */
117
129
  function streamSpecToQuery(spec) {
118
130
  if (spec.mode === "pty") return {
@@ -124,7 +136,8 @@ function streamSpecToQuery(spec) {
124
136
  if (spec.mode === "exec") return {
125
137
  agentId: spec.agentId,
126
138
  mode: "exec",
127
- command: spec.command
139
+ command: spec.command,
140
+ ...spec.timeoutMs !== void 0 ? { timeoutMs: String(spec.timeoutMs) } : {}
128
141
  };
129
142
  return {
130
143
  agentId: spec.agentId,
@@ -273,7 +286,8 @@ var SandboxStream = class SandboxStream {
273
286
  } : {
274
287
  mode: "exec",
275
288
  agentId: opts.agentId,
276
- command: opts.command
289
+ command: opts.command,
290
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
277
291
  };
278
292
  const url = new URL(`${wsBase(opts.appUrl)}${SANDBOX_STREAM_PATH}`);
279
293
  for (const [key, value] of Object.entries(streamSpecToQuery(spec))) url.searchParams.set(key, value);
@@ -293,4 +307,4 @@ function toBuffer(data) {
293
307
  }
294
308
 
295
309
  //#endregion
296
- export { encodeFsRequest as a, decodeFsResponse as i, SANDBOX_STREAM_PATH as n, streamSpecToQuery as r, SandboxStream as t };
310
+ export { FS_MAX_BLOB_BYTES as a, streamSpecToQuery as i, DEFAULT_EXEC_TIMEOUT_MS as n, decodeFsResponse as o, SANDBOX_STREAM_PATH as r, encodeFsRequest as s, SandboxStream as t };
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { n as PortalClient, r as RATE_LIMIT_STAND_DOWN_MS, t as AUTH_STAND_DOWN_MS } from "./client-CG57X0JA.mjs";
3
+ import "./api-BFQ4PQDA.mjs";
4
+
5
+ export { PortalClient };
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { i as isRecord, n as isNewerBuild, r as portalDaemonBuild, t as PortalClient } from "./client-CNF1DFAQ.mjs";
2
+ import { a as portalDaemonBuild, i as isNewerBuild, n as PortalClient, o as isRecord } from "./client-CG57X0JA.mjs";
3
3
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
6
7
  import { z } from "zod";
7
8
  import { spawn } from "node:child_process";
8
9
  import { createHash } from "node:crypto";
9
10
  import { connect, createServer } from "node:net";
10
- import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
11
11
 
12
12
  //#region ../portal-daemon/src/local-protocol.ts
13
13
  /**
@@ -66,6 +66,18 @@ function canonicalPortalAppUrl(appUrl) {
66
66
  if (host === "skydive.com" || host === "www.skydive.com" || host === "api.skydive.com") return "https://api.skydive.com";
67
67
  return trimmed;
68
68
  }
69
+ /** Prefix every Windows named pipe lives under (the pipe filesystem root). */
70
+ const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\";
71
+ /**
72
+ * Whether a daemon control-socket path is a Windows named pipe rather than a
73
+ * Unix-domain-socket FILE. Pipes have no on-disk file: they cannot be
74
+ * `stat`ed, `unlink`ed, or purged by a tmp cleaner, and the OS frees the name
75
+ * the moment the owning server closes — so every file-based lifecycle step
76
+ * (stale-file cleanup, the socket-ownership watch) must be skipped for them.
77
+ */
78
+ function isNamedPipePath(socketPath) {
79
+ return socketPath.startsWith(WINDOWS_PIPE_PREFIX);
80
+ }
69
81
  /**
70
82
  * Per-user, per-app daemon socket + state paths. Scoped by OS user and a hash
71
83
  * of the CANONICAL appUrl so two logins or two deployments (prod vs a PR
@@ -73,17 +85,42 @@ function canonicalPortalAppUrl(appUrl) {
73
85
  * every alias of one deployment (see {@link canonicalPortalAppUrl}) shares
74
86
  * one. Kept under the OS temp dir because Unix socket paths are
75
87
  * length-limited (~104 bytes on macOS).
88
+ *
89
+ * On Windows a `node:net` server cannot listen on a filesystem path at all —
90
+ * binding the tmp-dir path fails with EACCES and the daemon dies silently
91
+ * (ANY-7285's Windows facet) — so the socket is a named pipe under
92
+ * `\\.\pipe\`, keyed by the same (user, appUrl-hash) pair. State and log
93
+ * files stay under the temp dir on every platform.
94
+ *
95
+ * Darwin `sockaddr_un.sun_path` is 104 bytes including NUL (103 usable).
96
+ * `os.tmpdir()` on macOS is `$TMPDIR` (`/var/folders/<id>/T`, often ~50
97
+ * chars). Adding `skydive-portal-<user>-<hash>/daemon.sock` overruns that
98
+ * cap: bind() fails, the sidecar handshake still reports the long path, and
99
+ * connect() returns ENOENT (or Go's "path must be shorter than SUN_LEN").
100
+ * Stay in `os.tmpdir()` (do not relocate to `/tmp`) and shorten the leaf
101
+ * when the composed path would blow the cap. Windows named pipes have no
102
+ * sun_path, so they keep the long leaf.
76
103
  */
77
- function daemonPaths(appUrl) {
78
- const user = safeSegment(os.userInfo().username || "user");
79
- const app = createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12);
80
- const dir = path.join(os.tmpdir(), `skydive-portal-${user}-${app}`);
81
- return {
104
+ const SUN_PATH_MAX = 103;
105
+ function daemonPaths(appUrl, platform = process.platform) {
106
+ const parent = os.tmpdir();
107
+ const key = `skydive-portal-${safeSegment(os.userInfo().username || "user")}-${createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12)}`;
108
+ const dir = path.join(parent, key);
109
+ const paths = {
82
110
  dir,
83
- socketPath: path.join(dir, "daemon.sock"),
111
+ socketPath: platform === "win32" ? `${WINDOWS_PIPE_PREFIX}${key}` : path.join(dir, "daemon.sock"),
84
112
  statePath: path.join(dir, "state.json"),
85
113
  logPath: path.join(dir, "daemon.log")
86
114
  };
115
+ if (platform === "win32" || Buffer.byteLength(paths.socketPath) <= SUN_PATH_MAX) return paths;
116
+ const short = createHash("sha256").update(`${os.userInfo().username || "user"}\0${canonicalPortalAppUrl(appUrl)}`).digest("hex").slice(0, 12);
117
+ const shortDir = path.join(parent, `skydive-p-${short}`);
118
+ return {
119
+ dir: shortDir,
120
+ socketPath: path.join(shortDir, "d.sock"),
121
+ statePath: path.join(shortDir, "state.json"),
122
+ logPath: path.join(shortDir, "daemon.log")
123
+ };
87
124
  }
88
125
  function safeSegment(value) {
89
126
  return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "user";
@@ -344,6 +381,16 @@ const IDLE_SHUTDOWN_MS = 3e4;
344
381
  */
345
382
  const SOCKET_WATCH_TICK_MS = 3e4;
346
383
  /**
384
+ * Grace window after a server stand-down: the check fires the instant the
385
+ * close lands, but the WINNER's socket bind may still be in flight — a check
386
+ * run at t=0 can see our own still-good socket, conclude we are the owner, and
387
+ * redial into the supersede war the stand-down exists to end. On a
388
+ * stand-down-triggered check, wait this out once and re-stat before
389
+ * concluding ownership. The periodic watch tick runs grace-free (nothing just
390
+ * displaced us there).
391
+ */
392
+ const STAND_DOWN_GRACE_MS = 1500;
393
+ /**
347
394
  * Upper bound an incumbent daemon waits, during a graceful handoff, for the
348
395
  * successor to dial out and claim presence before giving up and releasing the
349
396
  * slot itself. Comfortably above a normal successor boot + WS dial (a few
@@ -370,12 +417,14 @@ var PortalDaemon = class {
370
417
  socketId = null;
371
418
  socketCheckRunning = false;
372
419
  socketWatchTickMs;
420
+ standDownGraceMs;
373
421
  exit;
374
422
  constructor(appUrl, options) {
375
423
  this.appUrl = canonicalPortalAppUrl(appUrl);
376
424
  this.paths = daemonPaths(this.appUrl);
377
425
  this.fallbackCwd = process.env.HOME ?? process.cwd();
378
426
  this.socketWatchTickMs = options?.socketWatchTickMs ?? SOCKET_WATCH_TICK_MS;
427
+ this.standDownGraceMs = options?.standDownGraceMs ?? STAND_DOWN_GRACE_MS;
379
428
  this.exit = options?.exit ?? (() => process.exit(0));
380
429
  }
381
430
  /** Start listening. Rejects if the socket is already held by another daemon. */
@@ -392,8 +441,10 @@ var PortalDaemon = class {
392
441
  resolve();
393
442
  });
394
443
  });
395
- await this.recordSocketIdentity();
396
- this.startSocketWatch();
444
+ if (!isNamedPipePath(this.paths.socketPath)) {
445
+ await this.recordSocketIdentity();
446
+ this.startSocketWatch();
447
+ }
397
448
  }
398
449
  /**
399
450
  * Remember which socket FILE we bound (dev+ino), so the watch can tell "our
@@ -421,24 +472,42 @@ var PortalDaemon = class {
421
472
  */
422
473
  startSocketWatch() {
423
474
  this.socketWatch = setInterval(() => {
424
- if (this.socketCheckRunning) return;
425
- this.socketCheckRunning = true;
426
- this.checkSocketOwnership().catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
427
- this.socketCheckRunning = false;
428
- });
475
+ this.runOwnershipCheck();
429
476
  }, this.socketWatchTickMs);
430
477
  this.socketWatch.unref();
431
478
  }
479
+ /** One socket-ownership pass, shared by the watch timer (grace-free) and the
480
+ * client's 'stand-down' state (with grace — see STAND_DOWN_GRACE_MS).
481
+ */
482
+ runOwnershipCheck(graceMs = 0) {
483
+ if (this.socketCheckRunning) return;
484
+ this.socketCheckRunning = true;
485
+ this.checkSocketOwnership(graceMs).catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
486
+ this.socketCheckRunning = false;
487
+ });
488
+ }
432
489
  stopSocketWatch() {
433
490
  if (this.socketWatch) {
434
491
  clearInterval(this.socketWatch);
435
492
  this.socketWatch = null;
436
493
  }
437
494
  }
438
- async checkSocketOwnership() {
495
+ async checkSocketOwnership(graceMs = 0) {
439
496
  if (!this.server || this.handingOff) return;
440
497
  const current = await stat(this.paths.socketPath).catch(() => null);
441
- if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) return;
498
+ if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) {
499
+ if (graceMs > 0) {
500
+ await new Promise((r) => setTimeout(r, graceMs));
501
+ const recheck = await stat(this.paths.socketPath).catch(() => null);
502
+ const socketId = this.socketId;
503
+ if (recheck && socketId && !(recheck.dev === socketId.dev && recheck.ino === socketId.ino)) {
504
+ this.logInfo("control socket taken over during stand-down grace; standing down");
505
+ this.standDown();
506
+ return;
507
+ }
508
+ }
509
+ return;
510
+ }
442
511
  if (current) {
443
512
  this.logInfo("control socket path is owned by another daemon; standing down");
444
513
  this.standDown();
@@ -490,6 +559,10 @@ var PortalDaemon = class {
490
559
  this.exit();
491
560
  }
492
561
  async clearStaleSocket() {
562
+ if (isNamedPipePath(this.paths.socketPath)) {
563
+ if (await isDaemonListening(this.paths.socketPath)) throw new Error("daemon already running");
564
+ return;
565
+ }
493
566
  if (!await pathExists(this.paths.socketPath)) return;
494
567
  if (await new Promise((resolve) => {
495
568
  const probe = connect(this.paths.socketPath);
@@ -605,13 +678,23 @@ var PortalDaemon = class {
605
678
  tlsCertSource: defaultTlsCertSource(process.env, (msg) => this.logInfo(msg)),
606
679
  persistedMachineName: this.persistedMachineName,
607
680
  onMachineName: (name, source) => this.onMachineName(name, source),
608
- onState: (state) => {
609
- this.lastState = state;
610
- this.broadcastState();
611
- }
681
+ onState: (state) => this.handleClientState(state)
612
682
  });
613
683
  }
614
684
  /**
685
+ * Client state transition. A server stand-down ('stand-down' status — the
686
+ * superseded/flap-cooldown close, distinguishable from an ordinary close
687
+ * through the status) runs the ownership check NOW with grace, so a
688
+ * duplicate that already lost the control socket yields within one server
689
+ * round trip instead of waiting for the next watch tick and fighting
690
+ * (supersede war).
691
+ */
692
+ handleClientState(state) {
693
+ if (state.status === "stand-down") this.runOwnershipCheck(this.standDownGraceMs);
694
+ this.lastState = state;
695
+ this.broadcastState();
696
+ }
697
+ /**
615
698
  * The identity resolved on connect. Persist the name so a later scutil
616
699
  * failure reuses it instead of adopting the volatile hostname, and log the
617
700
  * source — a `hostname-fallback` after we'd previously registered under
@@ -693,7 +776,7 @@ var PortalDaemon = class {
693
776
  appUrl: this.appUrl,
694
777
  build: portalDaemonBuild(),
695
778
  portal: {
696
- status: portal?.status ?? "off",
779
+ status: portal?.status === "stand-down" ? "connecting" : portal?.status ?? "off",
697
780
  machineName: portal?.machineName ?? "",
698
781
  friendlyName: portal?.friendlyName ?? "",
699
782
  error: portal?.error ?? null,
@@ -786,13 +869,19 @@ var PortalDaemon = class {
786
869
  * drains attached clients, who reconnect within ~500ms) and spawn from this
787
870
  * binary. Both spawners apply the same rule, so a host converges on the
788
871
  * newest installed build with at most one bounce.
872
+ *
873
+ * Returns whether a daemon is actually listening when it's done. A spawned
874
+ * daemon that never binds (its process died on startup — e.g. the Windows
875
+ * EACCES bind failure this used to swallow) resolves `false` so callers can
876
+ * SAY so, instead of silently handing the caller a socket nobody serves and
877
+ * leaving the reconnect loop to spin forever with no visible error.
789
878
  */
790
879
  async function ensureDaemonRunning(appUrl) {
791
880
  const { socketPath } = daemonPaths(appUrl);
792
881
  if (await isDaemonListening(socketPath)) {
793
882
  const status = await queryDaemonStatus(appUrl);
794
- if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
795
- if (await stopDaemonForHandoff(appUrl) === "failed") return;
883
+ if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return true;
884
+ if (await stopDaemonForHandoff(appUrl) === "failed") return true;
796
885
  }
797
886
  const entry = process.argv[1];
798
887
  const args = entry ? [
@@ -806,9 +895,10 @@ async function ensureDaemonRunning(appUrl) {
806
895
  env: process.env
807
896
  }).unref();
808
897
  for (let i = 0; i < 50; i += 1) {
809
- if (await isDaemonListening(socketPath)) return;
898
+ if (await isDaemonListening(socketPath)) return true;
810
899
  await sleep(100);
811
900
  }
901
+ return false;
812
902
  }
813
903
  /**
814
904
  * Entry the CLI calls when launched with PORTAL_DAEMON_FLAG: become the daemon.
@@ -829,7 +919,7 @@ function startPortalDaemon(appUrl) {
829
919
  return new PortalDaemon(appUrl, null).listen();
830
920
  }
831
921
  async function isDaemonListening(socketPath) {
832
- if (!await pathExists(socketPath)) return false;
922
+ if (!isNamedPipePath(socketPath) && !await pathExists(socketPath)) return false;
833
923
  return new Promise((resolve) => {
834
924
  const probe = connect(socketPath);
835
925
  probe.on("connect", () => {
@@ -956,7 +1046,7 @@ async function stopDaemon(appUrl) {
956
1046
  function stateMessage(state, declined) {
957
1047
  return {
958
1048
  t: "state",
959
- status: state.status,
1049
+ status: state.status === "stand-down" ? "connecting" : state.status,
960
1050
  machineName: state.machineName,
961
1051
  friendlyName: state.friendlyName,
962
1052
  error: state.error,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import "./client-CNF1DFAQ.mjs";
2
+ import "./client-CG57X0JA.mjs";
3
3
  import "./tls-cert-Rua2oV7n.mjs";
4
4
  import "./api-BFQ4PQDA.mjs";
5
- import { a as runPortalDaemon, c as stopDaemonForHandoff, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-D4B5LeNQ.mjs";
5
+ import { a as runPortalDaemon, c as stopDaemonForHandoff, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-CQIg-oge.mjs";
6
6
 
7
7
  export { runPortalDaemon };
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-D4B5LeNQ.mjs";
2
+ import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-CQIg-oge.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { connect } from "node:net";
5
5
 
6
6
  //#region ../portal-daemon/src/daemon-client.ts
7
+ /** Reconnect delay when an established daemon connection drops. */
8
+ const RECONNECT_DELAY_MS = 500;
9
+ /** Base delay between daemon SPAWN retries (doubles per consecutive failure). */
10
+ const START_RETRY_BASE_MS = 2e3;
11
+ /** Consecutive failed spawns before start() stops respawning. */
12
+ const MAX_START_ATTEMPTS = 3;
7
13
  var PortalDaemonClient = class {
8
14
  sessionId = randomUUID();
9
15
  socket = null;
10
16
  disposed = false;
11
17
  reconnectTimer = null;
18
+ startFailures = 0;
12
19
  lastBind = null;
13
20
  fallbackCwd = null;
14
21
  wantEnabled = false;
@@ -21,7 +28,21 @@ var PortalDaemonClient = class {
21
28
  /** Spawn the daemon if needed and attach. Idempotent. */
22
29
  async start() {
23
30
  if (this.disposed) return;
24
- await ensureDaemonRunning(this.opts.appUrl);
31
+ if (!await ensureDaemonRunning(this.opts.appUrl)) {
32
+ this.startFailures += 1;
33
+ const exhausted = this.startFailures >= MAX_START_ATTEMPTS;
34
+ this.opts.onState({
35
+ status: "error",
36
+ machineName: "",
37
+ friendlyName: "",
38
+ error: exhausted ? "portal daemon failed to start repeatedly; toggle sharing to retry" : "portal daemon failed to start (control socket never came up)",
39
+ grantedAgentIds: [],
40
+ declinedAgentIds: []
41
+ });
42
+ if (!exhausted) this.scheduleReconnect(START_RETRY_BASE_MS * 2 ** (this.startFailures - 1));
43
+ return;
44
+ }
45
+ this.startFailures = 0;
25
46
  this.connect();
26
47
  }
27
48
  connect() {
@@ -74,12 +95,12 @@ var PortalDaemonClient = class {
74
95
  socket.on("close", onClose);
75
96
  socket.on("error", onClose);
76
97
  }
77
- scheduleReconnect() {
98
+ scheduleReconnect(delayMs = RECONNECT_DELAY_MS) {
78
99
  if (this.disposed || this.reconnectTimer) return;
79
100
  this.reconnectTimer = setTimeout(() => {
80
101
  this.reconnectTimer = null;
81
102
  this.start();
82
- }, 500);
103
+ }, delayMs);
83
104
  this.reconnectTimer.unref();
84
105
  }
85
106
  onMessage(msg) {
@@ -109,6 +130,10 @@ var PortalDaemonClient = class {
109
130
  /** Turn machine sharing on (the daemon connects the portal). */
110
131
  enable() {
111
132
  this.wantEnabled = true;
133
+ if (this.startFailures >= MAX_START_ATTEMPTS) {
134
+ this.startFailures = 0;
135
+ this.start();
136
+ }
112
137
  this.send({ t: "enable" });
113
138
  }
114
139
  /** Turn machine sharing off. */
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import "./client-CG57X0JA.mjs";
3
+ import "./tls-cert-Rua2oV7n.mjs";
4
+ import "./api-BFQ4PQDA.mjs";
5
+ import "./daemon-CQIg-oge.mjs";
6
+ import { t as PortalDaemonClient } from "./daemon-client-BbDw7t4U.mjs";
7
+
8
+ export { PortalDaemonClient };