skydive-cli 0.5.0-beta.9 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +61 -13
- package/dist/js/api-BFQ4PQDA.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +674 -307
- package/dist/js/{boot-Q-Kh3nn5.mjs → boot-DD4T-61U.mjs} +4558 -930
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-DabRpc_T.mjs → client--k9cjfkX.mjs} +437 -39
- package/dist/js/{client-c4c5MmgN.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/client-Ct0-JZSS.mjs +5 -0
- package/dist/js/daemon-CCgNLD0H.mjs +7 -0
- package/dist/js/{daemon-D21wQ7DI.mjs → daemon-Do1jU2UF.mjs} +123 -43
- package/dist/js/daemon-client-C7nE-lLK.mjs +8 -0
- package/dist/js/{daemon-client-DPUNjhBB.mjs → daemon-client-Dvad009G.mjs} +1 -1
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-C-f04uyE.mjs +208 -0
- package/dist/js/{profiler-BkCV__ao.mjs → install-CtAVvERm.mjs} +545 -215
- package/dist/js/launcher.mjs +49 -0
- package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
- package/dist/js/{print-Wakr3GJd.mjs → print-Bx8qUC9U.mjs} +3 -3
- package/dist/js/{print-BpuyEfWX.mjs → print-D_UEjdSw.mjs} +257 -35
- package/dist/js/{print-share-CKLPmsg0.mjs → print-share-Cz0EO2RK.mjs} +9 -3
- package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
- package/dist/js/{raw-pty-DY4KelZW.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
- package/dist/js/{rest-I3imNduB.mjs → rest-B9__Zsuk.mjs} +144 -19
- package/dist/js/rest-Dc0EEok3.mjs +6 -0
- package/dist/js/tls-cert-BpCaD5AT.mjs +4 -0
- package/dist/js/tls-cert-Rua2oV7n.mjs +67 -0
- package/package.json +12 -4
- package/dist/js/api-DG5W6iwx.mjs +0 -131
- package/dist/js/client-BuU34IVE.mjs +0 -5
- package/dist/js/daemon-LSDSvMaC.mjs +0 -6
- package/dist/js/daemon-client-CUSq-Wuh.mjs +0 -7
- package/dist/js/forward-18QoL5dO.mjs +0 -68
- package/dist/js/raw-pty-DmdUf4_w.mjs +0 -5
- package/dist/js/rest-D29qNkto.mjs +0 -6
- /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
- /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
- /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-
|
|
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
|
|
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)
|
|
19
|
-
*
|
|
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
|
|
26
|
-
if (process.platform !== "darwin")
|
|
27
|
-
machineName
|
|
28
|
-
|
|
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
|
|
61
|
+
const fallback = fallbackHost();
|
|
31
62
|
return {
|
|
32
|
-
machineName:
|
|
33
|
-
friendlyName:
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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", () =>
|
|
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
|
|
434
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1051
|
+
export { resolveMachineIdentity as a, isRecord as i, isNewerBuild as n, portalDaemonBuild as r, PortalClient as t };
|