skydive-cli 0.5.0-beta.5 → 0.5.0-beta.52
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 +12 -0
- package/README.md +60 -12
- package/dist/js/api-TwLD7ibI.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +658 -279
- package/dist/js/{boot-Brwh0it_.mjs → boot-D_VFIRJj.mjs} +3556 -995
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-Cn2af31H.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/client-CkPQG8M1.mjs +5 -0
- package/dist/js/client-aFwHzCPG.mjs +963 -0
- package/dist/js/daemon-CORAITjj.mjs +7 -0
- package/dist/js/{daemon-client-DcuD4v12.mjs → daemon-client-Cfi66Xy9.mjs} +12 -8
- package/dist/js/daemon-client-DJPW9cRp.mjs +8 -0
- package/dist/js/{daemon-BhArnzeW.mjs → daemon-uzpOdRPL.mjs} +135 -10
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-Ct8Vd2WW.mjs +208 -0
- package/dist/js/{profiler-LLaIFZgn.mjs → install-BBplm-Zp.mjs} +529 -215
- package/dist/js/launcher.mjs +49 -0
- package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
- package/dist/js/{print-ba_0hiV9.mjs → print-Cuao6LN6.mjs} +3 -3
- package/dist/js/{print-CbayCa87.mjs → print-DR6Gas-M.mjs} +291 -39
- package/dist/js/{print-share-uwUG16Ov.mjs → print-share-BypBUrNh.mjs} +9 -3
- package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
- package/dist/js/{raw-pty-B6mAroiI.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
- package/dist/js/rest-CJkBP2Jz.mjs +6 -0
- package/dist/js/{rest-BY2nADw5.mjs → rest-DkuT5_oX.mjs} +117 -28
- package/dist/js/tls-cert-CLgSQALB.mjs +4 -0
- package/dist/js/tls-cert-CV-pwxVN.mjs +67 -0
- package/package.json +11 -4
- package/dist/js/client-DZstLQ_1.mjs +0 -4
- package/dist/js/client-Dd5sMXPv.mjs +0 -620
- package/dist/js/daemon-CjzrUvXx.mjs +0 -5
- package/dist/js/daemon-client-D8bNXTxu.mjs +0 -6
- package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
- package/dist/js/rest-DADJh0bi.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,963 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, l as startTlsForward, n as fetchPortalDevices, r as findThisDevice } from "./api-TwLD7ibI.mjs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import * as childProcess from "node:child_process";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { WebSocket, createWebSocketStream } from "ws";
|
|
9
|
+
|
|
10
|
+
//#region ../portal-daemon/src/machine.ts
|
|
11
|
+
const SCUTIL_TIMEOUT_MS = 2e3;
|
|
12
|
+
const SCUTIL_ATTEMPTS = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Identity this machine registers under on the portal.
|
|
15
|
+
*
|
|
16
|
+
* One device row per (org, user, machine): the daemon owns the host's single
|
|
17
|
+
* portal connection, and every surface (CLI, TUI, desktop app) shares that
|
|
18
|
+
* row, so a grant applies to the machine no matter which surface the user
|
|
19
|
+
* shared from. The name must therefore match what the desktop app registers —
|
|
20
|
+
* on macOS that is `scutil --get LocalHostName` (the Rust client's own
|
|
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).
|
|
36
|
+
*
|
|
37
|
+
* Historically the CLI suffixed `-cli` to keep a device distinct from the
|
|
38
|
+
* desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
|
|
39
|
+
*/
|
|
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"
|
|
60
|
+
};
|
|
61
|
+
const fallback = fallbackHost();
|
|
62
|
+
return {
|
|
63
|
+
machineName: fallback,
|
|
64
|
+
friendlyName: fallback,
|
|
65
|
+
machineNameSource: "hostname-fallback"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function fallbackHost() {
|
|
69
|
+
return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
70
|
+
}
|
|
71
|
+
function scutilRead(key) {
|
|
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
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const INHERITED_ENV = [
|
|
91
|
+
"HOME",
|
|
92
|
+
"USER",
|
|
93
|
+
"LOGNAME",
|
|
94
|
+
"SHELL",
|
|
95
|
+
"LANG",
|
|
96
|
+
"LC_ALL",
|
|
97
|
+
"TMPDIR",
|
|
98
|
+
"TERM",
|
|
99
|
+
"PATH"
|
|
100
|
+
];
|
|
101
|
+
const DARWIN_PATH_PREPEND = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
102
|
+
function buildEnv(extra) {
|
|
103
|
+
const env = {};
|
|
104
|
+
for (const key of INHERITED_ENV) {
|
|
105
|
+
const value = process.env[key];
|
|
106
|
+
if (value !== void 0) env[key] = value;
|
|
107
|
+
}
|
|
108
|
+
if (process.platform === "darwin") {
|
|
109
|
+
const current = env.PATH ? env.PATH.split(":") : [];
|
|
110
|
+
const missing = DARWIN_PATH_PREPEND.filter((dir) => !current.includes(dir));
|
|
111
|
+
if (missing.length > 0) env.PATH = [...missing, ...current].join(":");
|
|
112
|
+
}
|
|
113
|
+
if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
|
|
114
|
+
return env;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
|
|
118
|
+
* desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
|
|
119
|
+
* machine/label ride as query pairs (percent-encoded by URL).
|
|
120
|
+
*/
|
|
121
|
+
function portalWsUrl(appUrl, machine, label) {
|
|
122
|
+
const base = appUrl.replace(/\/+$/, "");
|
|
123
|
+
let wsBase;
|
|
124
|
+
if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
|
|
125
|
+
else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
|
|
126
|
+
else wsBase = `wss://${base}`;
|
|
127
|
+
const url = new URL(`${wsBase}/api/v1/portal/desktop`);
|
|
128
|
+
url.searchParams.set("machine", machine);
|
|
129
|
+
url.searchParams.set("label", label);
|
|
130
|
+
return url.toString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region ../portal-daemon/src/util.ts
|
|
135
|
+
/** Narrowing helper for the unknown JSON payloads the daemon reads (persisted
|
|
136
|
+
* state, wire frames). A type predicate (not an `as` cast), so call sites can
|
|
137
|
+
* read properties without asserting. */
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
/** Best-effort message from an unknown thrown value. */
|
|
142
|
+
function errorMessage(err) {
|
|
143
|
+
return err instanceof Error ? err.message : String(err);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region ../portal-protocol/src/index.ts
|
|
148
|
+
const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
|
|
149
|
+
const T_DATA = 1;
|
|
150
|
+
const T_CTRL = 2;
|
|
151
|
+
const STREAM = {
|
|
152
|
+
stdout: 0,
|
|
153
|
+
stderr: 1,
|
|
154
|
+
stdin: 2
|
|
155
|
+
};
|
|
156
|
+
const uuidToBytes = (id) => Buffer.from(id.replace(/-/g, ""), "hex");
|
|
157
|
+
const bytesToUuid = (b) => {
|
|
158
|
+
const h = b.toString("hex");
|
|
159
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
160
|
+
};
|
|
161
|
+
function encodeData(id, stream, seq, payload) {
|
|
162
|
+
const head = Buffer.allocUnsafe(22);
|
|
163
|
+
head[0] = T_DATA;
|
|
164
|
+
uuidToBytes(id).copy(head, 1);
|
|
165
|
+
head[17] = stream;
|
|
166
|
+
head.writeUInt32BE(seq >>> 0, 18);
|
|
167
|
+
return Buffer.concat([head, payload]);
|
|
168
|
+
}
|
|
169
|
+
const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
170
|
+
z.object({
|
|
171
|
+
t: z.literal("open"),
|
|
172
|
+
argv: z.array(z.string()),
|
|
173
|
+
env: z.record(z.string()).nullable(),
|
|
174
|
+
conversationId: z.string().nullable().optional()
|
|
175
|
+
}),
|
|
176
|
+
z.object({ t: z.literal("stdin_eof") }),
|
|
177
|
+
z.object({
|
|
178
|
+
t: z.literal("reverse_listen"),
|
|
179
|
+
listenPort: z.number().int().positive(),
|
|
180
|
+
targetPort: z.number().int().positive(),
|
|
181
|
+
dialOrigin: z.string().min(1),
|
|
182
|
+
token: z.string().min(1)
|
|
183
|
+
}),
|
|
184
|
+
z.object({ t: z.literal("reverse_listening") }),
|
|
185
|
+
z.object({
|
|
186
|
+
t: z.literal("reverse_token"),
|
|
187
|
+
token: z.string().min(1)
|
|
188
|
+
}),
|
|
189
|
+
z.object({ t: z.literal("pause") }),
|
|
190
|
+
z.object({ t: z.literal("resume") }),
|
|
191
|
+
z.object({ t: z.literal("cancel") }),
|
|
192
|
+
z.object({
|
|
193
|
+
t: z.literal("close"),
|
|
194
|
+
exitCode: z.number(),
|
|
195
|
+
frames: z.number().int().nonnegative().optional()
|
|
196
|
+
}),
|
|
197
|
+
z.object({
|
|
198
|
+
t: z.literal("error"),
|
|
199
|
+
message: z.string()
|
|
200
|
+
})
|
|
201
|
+
]);
|
|
202
|
+
function encodeCtrl(id, obj) {
|
|
203
|
+
const head = Buffer.allocUnsafe(17);
|
|
204
|
+
head[0] = T_CTRL;
|
|
205
|
+
uuidToBytes(id).copy(head, 1);
|
|
206
|
+
return Buffer.concat([head, Buffer.from(JSON.stringify(obj), "utf8")]);
|
|
207
|
+
}
|
|
208
|
+
function decodeFrame(frame) {
|
|
209
|
+
const id = bytesToUuid(frame.subarray(1, 17));
|
|
210
|
+
if (frame[0] === T_DATA) return {
|
|
211
|
+
kind: "data",
|
|
212
|
+
id,
|
|
213
|
+
stream: frame[17] ?? 0,
|
|
214
|
+
seq: frame.readUInt32BE(18),
|
|
215
|
+
payload: frame.subarray(22)
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
kind: "ctrl",
|
|
219
|
+
id,
|
|
220
|
+
obj: ctrlMessageSchema.parse(JSON.parse(frame.subarray(17).toString("utf8")))
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region ../portal-daemon/src/reverse-listener.ts
|
|
226
|
+
/**
|
|
227
|
+
* The desktop half of an agent-initiated expose tunnel (`platform portal
|
|
228
|
+
* expose`): listen on the local loopback and pipe each accepted TCP
|
|
229
|
+
* connection to the agent sandbox's daemon (`/portal/tcp`) through the
|
|
230
|
+
* agent-webserver edge Worker — the same per-connection dial-and-pipe as the
|
|
231
|
+
* user-initiated `skydive portal forward`, just started from a directive
|
|
232
|
+
* instead of a terminal. Tunnel bytes never touch the directive stream that
|
|
233
|
+
* created this listener; it only anchors the lifetime and carries the token.
|
|
234
|
+
*
|
|
235
|
+
* `target()` is read per connection so a token refresh (or any future
|
|
236
|
+
* retarget) applies to the next dial without touching established pipes.
|
|
237
|
+
*/
|
|
238
|
+
var ReverseListener = class {
|
|
239
|
+
server = null;
|
|
240
|
+
tls = null;
|
|
241
|
+
conns = /* @__PURE__ */ new Set();
|
|
242
|
+
constructor(opts) {
|
|
243
|
+
this.opts = opts;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* One tunnel to the sandbox target — the same per-connection edge dial the
|
|
247
|
+
* plain accept path inlines, shaped for the TLS listener's acquireTunnel
|
|
248
|
+
* seam. Resolves once the upgrade completes, with the duplex PAUSED so no
|
|
249
|
+
* early bytes drop before adoption.
|
|
250
|
+
*/
|
|
251
|
+
dialTunnel() {
|
|
252
|
+
const { dialOrigin, targetPort, token } = this.opts.target();
|
|
253
|
+
const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
ws.once("error", reject);
|
|
256
|
+
ws.once("unexpected-response", (_req, res) => {
|
|
257
|
+
res.destroy();
|
|
258
|
+
reject(/* @__PURE__ */ new Error(`tunnel rejected (HTTP ${res.statusCode ?? "?"})`));
|
|
259
|
+
});
|
|
260
|
+
ws.once("open", () => {
|
|
261
|
+
const stream = createWebSocketStream(ws);
|
|
262
|
+
stream.pause();
|
|
263
|
+
resolve({
|
|
264
|
+
stream,
|
|
265
|
+
close: () => stream.destroy()
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
start() {
|
|
271
|
+
const server = net.createServer((sock) => {
|
|
272
|
+
this.conns.add(sock);
|
|
273
|
+
sock.once("close", () => this.conns.delete(sock));
|
|
274
|
+
sock.pause();
|
|
275
|
+
const { dialOrigin, targetPort, token } = this.opts.target();
|
|
276
|
+
const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
|
|
277
|
+
ws.on("open", () => {
|
|
278
|
+
const stream = createWebSocketStream(ws);
|
|
279
|
+
stream.on("error", () => sock.destroy());
|
|
280
|
+
sock.on("error", () => stream.destroy());
|
|
281
|
+
sock.pipe(stream).pipe(sock);
|
|
282
|
+
sock.resume();
|
|
283
|
+
});
|
|
284
|
+
ws.on("error", (err) => {
|
|
285
|
+
this.opts.log(`expose: tunnel connect failed: ${err.message}`);
|
|
286
|
+
sock.destroy();
|
|
287
|
+
});
|
|
288
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
289
|
+
this.opts.log(`expose: tunnel rejected (HTTP ${res.statusCode ?? "?"})`);
|
|
290
|
+
res.destroy();
|
|
291
|
+
sock.destroy();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
server.once("error", (err) => this.opts.onListening(err));
|
|
295
|
+
server.listen(this.opts.listenPort, "127.0.0.1", () => {
|
|
296
|
+
this.opts.onListening(null);
|
|
297
|
+
this.startTls();
|
|
298
|
+
});
|
|
299
|
+
this.server = server;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The additive TLS+h2 twin (fire-and-forget from start): browsers negotiate
|
|
303
|
+
* h2 there, lifting the 6-connection cap that serializes module-heavy dev
|
|
304
|
+
* servers. Every failure logs once and leaves the plain listener — the
|
|
305
|
+
* product's default behavior — untouched. The ack (`reverse_listening`)
|
|
306
|
+
* deliberately stays keyed to the plain bind: TLS is an enhancement, never
|
|
307
|
+
* a condition of "the tunnel is up".
|
|
308
|
+
*/
|
|
309
|
+
startTls() {
|
|
310
|
+
const source = this.opts.tlsCertSource;
|
|
311
|
+
if (!source) return;
|
|
312
|
+
(async () => {
|
|
313
|
+
try {
|
|
314
|
+
const cert = await source();
|
|
315
|
+
if (!cert || this.server === null) return;
|
|
316
|
+
this.tls = await startTlsForward({
|
|
317
|
+
cert,
|
|
318
|
+
localPort: this.opts.listenPort,
|
|
319
|
+
targetPort: this.opts.target().targetPort,
|
|
320
|
+
acquireTunnel: () => this.dialTunnel(),
|
|
321
|
+
log: this.opts.log
|
|
322
|
+
});
|
|
323
|
+
if (this.server === null) {
|
|
324
|
+
const straggler = this.tls;
|
|
325
|
+
this.tls = null;
|
|
326
|
+
await straggler.close();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.opts.log(`expose: TLS origin up: https://${this.tls.hostname}:${this.tls.port} (HTTP/2)`);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.opts.log(`expose: TLS origin unavailable (${error instanceof Error ? error.message : String(error)}); http listener unaffected`);
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
334
|
+
}
|
|
335
|
+
stop() {
|
|
336
|
+
this.server?.close();
|
|
337
|
+
this.server = null;
|
|
338
|
+
const tls = this.tls;
|
|
339
|
+
this.tls = null;
|
|
340
|
+
if (tls) tls.close();
|
|
341
|
+
for (const sock of this.conns) sock.destroy();
|
|
342
|
+
this.conns.clear();
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region ../portal-daemon/src/exec.ts
|
|
348
|
+
/**
|
|
349
|
+
* Runs portal `exec` directives locally. Each `open` spawns a child process
|
|
350
|
+
* whose stdout/stderr stream back as data frames and whose stdin is fed by
|
|
351
|
+
* inbound data frames, with pause/resume backpressure and cancel/teardown that
|
|
352
|
+
* kill the child. This is the TypeScript counterpart of the desktop's Rust
|
|
353
|
+
* `portal/mod.rs` job machinery, minus the connection supervision (which lives
|
|
354
|
+
* in the client).
|
|
355
|
+
*/
|
|
356
|
+
var JobManager = class {
|
|
357
|
+
jobs = /* @__PURE__ */ new Map();
|
|
358
|
+
reverse = /* @__PURE__ */ new Map();
|
|
359
|
+
constructor(opts) {
|
|
360
|
+
this.opts = opts;
|
|
361
|
+
}
|
|
362
|
+
handleFrame(raw) {
|
|
363
|
+
let decoded;
|
|
364
|
+
try {
|
|
365
|
+
decoded = decodeFrame(raw);
|
|
366
|
+
} catch (_error) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (decoded.kind === "ctrl") this.handleCtrl(decoded.id, decoded.obj);
|
|
370
|
+
else if (decoded.stream === STREAM.stdin) this.jobs.get(decoded.id)?.child.stdin.write(decoded.payload);
|
|
371
|
+
}
|
|
372
|
+
killAll() {
|
|
373
|
+
for (const job of this.jobs.values()) {
|
|
374
|
+
job.settled = true;
|
|
375
|
+
job.child.kill("SIGKILL");
|
|
376
|
+
}
|
|
377
|
+
this.jobs.clear();
|
|
378
|
+
for (const rev of this.reverse.values()) {
|
|
379
|
+
rev.settled = true;
|
|
380
|
+
rev.listener.stop();
|
|
381
|
+
}
|
|
382
|
+
this.reverse.clear();
|
|
383
|
+
}
|
|
384
|
+
handleCtrl(id, msg) {
|
|
385
|
+
switch (msg.t) {
|
|
386
|
+
case "open":
|
|
387
|
+
this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
|
|
388
|
+
return;
|
|
389
|
+
case "reverse_listen":
|
|
390
|
+
this.startReverse(id, {
|
|
391
|
+
listenPort: msg.listenPort,
|
|
392
|
+
target: {
|
|
393
|
+
dialOrigin: msg.dialOrigin,
|
|
394
|
+
targetPort: msg.targetPort,
|
|
395
|
+
token: msg.token
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
case "reverse_token": {
|
|
400
|
+
const rev = this.reverse.get(id);
|
|
401
|
+
if (rev) rev.target = {
|
|
402
|
+
...rev.target,
|
|
403
|
+
token: msg.token
|
|
404
|
+
};
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
case "stdin_eof":
|
|
408
|
+
this.jobs.get(id)?.child.stdin.end();
|
|
409
|
+
return;
|
|
410
|
+
case "pause":
|
|
411
|
+
this.setPaused(id, true);
|
|
412
|
+
return;
|
|
413
|
+
case "resume":
|
|
414
|
+
this.setPaused(id, false);
|
|
415
|
+
return;
|
|
416
|
+
case "cancel":
|
|
417
|
+
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
418
|
+
this.stopReverse(id);
|
|
419
|
+
return;
|
|
420
|
+
case "close":
|
|
421
|
+
case "error":
|
|
422
|
+
case "reverse_listening": return;
|
|
423
|
+
default: return msg;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
startReverse(id, { listenPort, target }) {
|
|
427
|
+
const rev = {
|
|
428
|
+
settled: false,
|
|
429
|
+
target,
|
|
430
|
+
seq: 0,
|
|
431
|
+
listener: new ReverseListener({
|
|
432
|
+
listenPort,
|
|
433
|
+
target: () => rev.target,
|
|
434
|
+
tlsCertSource: this.opts.tlsCertSource,
|
|
435
|
+
log: (msg) => {
|
|
436
|
+
if (rev.settled) return;
|
|
437
|
+
this.opts.send(encodeData(id, STREAM.stderr, rev.seq, Buffer.from(msg)));
|
|
438
|
+
rev.seq = rev.seq + 1 >>> 0;
|
|
439
|
+
},
|
|
440
|
+
onListening: (err) => {
|
|
441
|
+
if (err) {
|
|
442
|
+
rev.settled = true;
|
|
443
|
+
this.reverse.delete(id);
|
|
444
|
+
this.opts.send(encodeCtrl(id, {
|
|
445
|
+
t: "error",
|
|
446
|
+
message: `reverse listen failed: ${err.message}`
|
|
447
|
+
}));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.opts.send(encodeCtrl(id, { t: "reverse_listening" }));
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
};
|
|
454
|
+
this.reverse.set(id, rev);
|
|
455
|
+
rev.listener.start();
|
|
456
|
+
}
|
|
457
|
+
stopReverse(id) {
|
|
458
|
+
const rev = this.reverse.get(id);
|
|
459
|
+
if (!rev) return;
|
|
460
|
+
rev.settled = true;
|
|
461
|
+
this.reverse.delete(id);
|
|
462
|
+
rev.listener.stop();
|
|
463
|
+
}
|
|
464
|
+
setPaused(id, paused) {
|
|
465
|
+
const job = this.jobs.get(id);
|
|
466
|
+
if (!job) return;
|
|
467
|
+
if (paused) {
|
|
468
|
+
job.child.stdout.pause();
|
|
469
|
+
job.child.stderr.pause();
|
|
470
|
+
} else {
|
|
471
|
+
job.child.stdout.resume();
|
|
472
|
+
job.child.stderr.resume();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
startJob(id, argv, env, conversationId) {
|
|
476
|
+
const [program, ...args] = argv;
|
|
477
|
+
if (!program) {
|
|
478
|
+
this.opts.send(encodeCtrl(id, {
|
|
479
|
+
t: "error",
|
|
480
|
+
message: "empty argv"
|
|
481
|
+
}));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
let child;
|
|
485
|
+
try {
|
|
486
|
+
child = spawn(program, args, {
|
|
487
|
+
cwd: this.opts.resolveCwd(conversationId),
|
|
488
|
+
env: buildEnv(env),
|
|
489
|
+
stdio: [
|
|
490
|
+
"pipe",
|
|
491
|
+
"pipe",
|
|
492
|
+
"pipe"
|
|
493
|
+
]
|
|
494
|
+
});
|
|
495
|
+
} catch (err) {
|
|
496
|
+
this.opts.send(encodeCtrl(id, {
|
|
497
|
+
t: "error",
|
|
498
|
+
message: `spawn failed: ${errorMessage(err)}`
|
|
499
|
+
}));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const job = {
|
|
503
|
+
child,
|
|
504
|
+
seq: 0,
|
|
505
|
+
settled: false
|
|
506
|
+
};
|
|
507
|
+
this.jobs.set(id, job);
|
|
508
|
+
child.on("error", (err) => {
|
|
509
|
+
if (job.settled) return;
|
|
510
|
+
job.settled = true;
|
|
511
|
+
this.jobs.delete(id);
|
|
512
|
+
this.opts.send(encodeCtrl(id, {
|
|
513
|
+
t: "error",
|
|
514
|
+
message: `spawn failed: ${errorMessage(err)}`
|
|
515
|
+
}));
|
|
516
|
+
});
|
|
517
|
+
child.stdout.on("data", (chunk) => this.sendData(job, id, STREAM.stdout, chunk));
|
|
518
|
+
child.stderr.on("data", (chunk) => this.sendData(job, id, STREAM.stderr, chunk));
|
|
519
|
+
child.on("close", (code) => {
|
|
520
|
+
if (job.settled) return;
|
|
521
|
+
job.settled = true;
|
|
522
|
+
this.jobs.delete(id);
|
|
523
|
+
this.opts.send(encodeCtrl(id, {
|
|
524
|
+
t: "close",
|
|
525
|
+
exitCode: code ?? -1,
|
|
526
|
+
frames: job.seq
|
|
527
|
+
}));
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
sendData(job, id, stream, chunk) {
|
|
531
|
+
if (job.settled) return;
|
|
532
|
+
this.opts.send(encodeData(id, stream, job.seq, chunk));
|
|
533
|
+
job.seq = job.seq + 1 >>> 0;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region ../portal-daemon/src/client.ts
|
|
539
|
+
const DEFAULT_HEARTBEAT = {
|
|
540
|
+
intervalMs: 15e3,
|
|
541
|
+
maxMissed: 2,
|
|
542
|
+
wakeTickMs: 2e3,
|
|
543
|
+
wakeGapThresholdMs: 1e4,
|
|
544
|
+
netWatchTickMs: 2e3,
|
|
545
|
+
initialBackoffMs: 500,
|
|
546
|
+
maxBackoffMs: 1e4
|
|
547
|
+
};
|
|
548
|
+
/**
|
|
549
|
+
* A cheap fingerprint of the machine's network identity: every non-internal
|
|
550
|
+
* interface name plus its IPv4 addresses. When it changes, the active network
|
|
551
|
+
* changed (interface came/went, DHCP handed out a new address, VPN tunnel
|
|
552
|
+
* up/down) and any live socket is riding a route that no longer exists.
|
|
553
|
+
* IPv6 addresses are deliberately excluded: macOS rotates temporary IPv6
|
|
554
|
+
* addresses on a schedule, which would fingerprint as a "change" and cause
|
|
555
|
+
* spurious reconnects — the interface NAME still captures a v6-only tunnel
|
|
556
|
+
* appearing or vanishing.
|
|
557
|
+
*/
|
|
558
|
+
function networkFingerprint() {
|
|
559
|
+
const parts = [];
|
|
560
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
561
|
+
let external = false;
|
|
562
|
+
for (const addr of addrs ?? []) {
|
|
563
|
+
if (addr.internal) continue;
|
|
564
|
+
external = true;
|
|
565
|
+
if (addr.family === "IPv4") parts.push(`${name}/${addr.address}`);
|
|
566
|
+
}
|
|
567
|
+
if (external) parts.push(name);
|
|
568
|
+
}
|
|
569
|
+
return parts.sort().join("|");
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Shares the local machine with agents over the portal: dials OUT to the api's
|
|
573
|
+
* desktop-portal WebSocket (authenticating with a short-lived device token
|
|
574
|
+
* minted from the CLI session), then runs inbound `exec` directives via a
|
|
575
|
+
* `JobManager`. Reconnects with backoff while enabled; disabling drops presence
|
|
576
|
+
* and kills any in-flight children. No inbound port is ever opened.
|
|
577
|
+
*
|
|
578
|
+
* Access stays default-deny: connecting only makes the machine reachable — an
|
|
579
|
+
* agent can't run anything until the user grants it (`grantAgent`).
|
|
580
|
+
*/
|
|
581
|
+
var PortalClient = class {
|
|
582
|
+
enabled = false;
|
|
583
|
+
disposed = false;
|
|
584
|
+
ws = null;
|
|
585
|
+
jobs = null;
|
|
586
|
+
status = "off";
|
|
587
|
+
error = null;
|
|
588
|
+
deviceId = null;
|
|
589
|
+
granted = /* @__PURE__ */ new Set();
|
|
590
|
+
machineName = "";
|
|
591
|
+
friendlyName = "";
|
|
592
|
+
legacyGrantsChecked = false;
|
|
593
|
+
wakeWatch = null;
|
|
594
|
+
lastTick = Date.now();
|
|
595
|
+
netWatch = null;
|
|
596
|
+
netFingerprint = networkFingerprint();
|
|
597
|
+
backoffKick = null;
|
|
598
|
+
handingOff = false;
|
|
599
|
+
handoffResolve = null;
|
|
600
|
+
heartbeatConfig;
|
|
601
|
+
constructor(opts) {
|
|
602
|
+
this.opts = opts;
|
|
603
|
+
this.heartbeatConfig = {
|
|
604
|
+
...DEFAULT_HEARTBEAT,
|
|
605
|
+
...opts.heartbeat
|
|
606
|
+
};
|
|
607
|
+
this.startWakeWatch();
|
|
608
|
+
this.startNetWatch();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Detect wake-from-sleep without an Electron/`powerMonitor` dependency (the
|
|
612
|
+
* daemon also runs headless under the CLI). A repeating timer that should
|
|
613
|
+
* fire every WAKE_TICK_MS; when the actual gap since the last tick far
|
|
614
|
+
* exceeds that, the process was suspended (macOS sleep, laptop lid) and the
|
|
615
|
+
* current socket — if any — is a zombie against a server that has already
|
|
616
|
+
* reaped our presence. Terminate it now so `connectLoop` redials at once
|
|
617
|
+
* instead of waiting out the heartbeat deadline. Cheap: one no-op timer.
|
|
618
|
+
*/
|
|
619
|
+
startWakeWatch() {
|
|
620
|
+
this.lastTick = Date.now();
|
|
621
|
+
this.wakeWatch = setInterval(() => {
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
const gap = now - this.lastTick;
|
|
624
|
+
this.lastTick = now;
|
|
625
|
+
if (gap > this.heartbeatConfig.wakeGapThresholdMs) this.forceReconnect("woke from sleep; reconnecting portal");
|
|
626
|
+
}, this.heartbeatConfig.wakeTickMs);
|
|
627
|
+
this.wakeWatch.unref?.();
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Detect a network switch — the awake sibling of the wake detector. Changing
|
|
631
|
+
* Wi-Fi↔ethernet, switching SSID, or toggling a VPN kills the socket's route
|
|
632
|
+
* with no FIN/RST, so `ws` may never fire 'close' and the heartbeat's
|
|
633
|
+
* missed-pong deadline is the best case (~2 intervals), not a guarantee.
|
|
634
|
+
* Poll the network-identity fingerprint and, the moment it changes, terminate
|
|
635
|
+
* the socket so `connectLoop` redials over the new network immediately.
|
|
636
|
+
* Cheap: `os.networkInterfaces()` is one syscall-backed read per tick.
|
|
637
|
+
*/
|
|
638
|
+
startNetWatch() {
|
|
639
|
+
this.netWatch = setInterval(() => {
|
|
640
|
+
const fingerprint = networkFingerprint();
|
|
641
|
+
if (fingerprint === this.netFingerprint) return;
|
|
642
|
+
this.netFingerprint = fingerprint;
|
|
643
|
+
this.forceReconnect("network changed; reconnecting portal");
|
|
644
|
+
}, this.heartbeatConfig.netWatchTickMs);
|
|
645
|
+
this.netWatch.unref?.();
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The one reaction every detector (wake, network change) converges on: make
|
|
649
|
+
* the connect loop re-evaluate NOW, whatever state it is in. With a live
|
|
650
|
+
* socket, terminate it — 'close' resolves `runConnection` and the loop
|
|
651
|
+
* redials. Between dials (backing off after a failure, e.g. while the
|
|
652
|
+
* network was down), cut the sleep short so the retry fires immediately — a
|
|
653
|
+
* dial that was pointless a moment ago is exactly right the moment the
|
|
654
|
+
* network comes back. These are per-machine local events, uncorrelated
|
|
655
|
+
* across the fleet, so the immediate retry can't herd; the backoff VALUE is
|
|
656
|
+
* untouched and still governs the next failure.
|
|
657
|
+
*/
|
|
658
|
+
forceReconnect(reason) {
|
|
659
|
+
if (this.ws) {
|
|
660
|
+
this.error = reason;
|
|
661
|
+
this.ws.terminate();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
this.kickBackoff();
|
|
665
|
+
}
|
|
666
|
+
kickBackoff() {
|
|
667
|
+
const kick = this.backoffKick;
|
|
668
|
+
this.backoffKick = null;
|
|
669
|
+
kick?.();
|
|
670
|
+
}
|
|
671
|
+
/** The between-redials sleep, interruptible by a detector (forceReconnect). */
|
|
672
|
+
backoffSleep(ms) {
|
|
673
|
+
return new Promise((resolve) => {
|
|
674
|
+
let done = false;
|
|
675
|
+
const finish = () => {
|
|
676
|
+
if (done) return;
|
|
677
|
+
done = true;
|
|
678
|
+
this.backoffKick = null;
|
|
679
|
+
resolve();
|
|
680
|
+
};
|
|
681
|
+
const timer = setTimeout(finish, ms);
|
|
682
|
+
this.backoffKick = () => {
|
|
683
|
+
clearTimeout(timer);
|
|
684
|
+
finish();
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
isEnabled() {
|
|
689
|
+
return this.enabled;
|
|
690
|
+
}
|
|
691
|
+
isGranted(agentId) {
|
|
692
|
+
return this.granted.has(agentId);
|
|
693
|
+
}
|
|
694
|
+
/** Agents currently authorized on this machine (last known server state). */
|
|
695
|
+
grantedAgentIds() {
|
|
696
|
+
return [...this.granted];
|
|
697
|
+
}
|
|
698
|
+
enable() {
|
|
699
|
+
if (this.enabled || this.disposed) return;
|
|
700
|
+
this.enabled = true;
|
|
701
|
+
this.error = null;
|
|
702
|
+
this.connectLoop();
|
|
703
|
+
}
|
|
704
|
+
disable() {
|
|
705
|
+
if (!this.enabled) return;
|
|
706
|
+
this.enabled = false;
|
|
707
|
+
this.jobs?.killAll();
|
|
708
|
+
this.ws?.close();
|
|
709
|
+
this.ws = null;
|
|
710
|
+
this.deviceId = null;
|
|
711
|
+
this.granted = /* @__PURE__ */ new Set();
|
|
712
|
+
this.kickBackoff();
|
|
713
|
+
this.setStatus("off");
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Tear down for good (app quit). Kills children synchronously and closes the
|
|
717
|
+
* socket so it stops holding the event loop open — otherwise the process
|
|
718
|
+
* would hang after the TUI is destroyed.
|
|
719
|
+
*/
|
|
720
|
+
dispose() {
|
|
721
|
+
this.disposed = true;
|
|
722
|
+
this.enabled = false;
|
|
723
|
+
if (this.wakeWatch) {
|
|
724
|
+
clearInterval(this.wakeWatch);
|
|
725
|
+
this.wakeWatch = null;
|
|
726
|
+
}
|
|
727
|
+
if (this.netWatch) {
|
|
728
|
+
clearInterval(this.netWatch);
|
|
729
|
+
this.netWatch = null;
|
|
730
|
+
}
|
|
731
|
+
this.jobs?.killAll();
|
|
732
|
+
this.jobs = null;
|
|
733
|
+
this.ws?.close();
|
|
734
|
+
this.ws = null;
|
|
735
|
+
this.kickBackoff();
|
|
736
|
+
this.finishHandoff();
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Begin a make-before-break handoff to a successor daemon. Keep the current
|
|
740
|
+
* portal socket (and thus this machine's presence claim) alive so there is no
|
|
741
|
+
* `registered but not connected` gap, but stop redialing once it closes — the
|
|
742
|
+
* successor's `claim()` supersedes us and the server closes our socket, which
|
|
743
|
+
* is our cue to step down. Resolves when the socket has closed (clean
|
|
744
|
+
* handoff) OR after `timeoutMs` (the successor never came up, so we release
|
|
745
|
+
* the slot rather than hold it forever). Idempotent.
|
|
746
|
+
*/
|
|
747
|
+
beginHandoff(timeoutMs) {
|
|
748
|
+
if (this.disposed) return Promise.resolve();
|
|
749
|
+
if (this.handingOff) return new Promise((resolve) => {
|
|
750
|
+
const prev = this.handoffResolve;
|
|
751
|
+
this.handoffResolve = () => {
|
|
752
|
+
prev?.();
|
|
753
|
+
resolve();
|
|
754
|
+
};
|
|
755
|
+
});
|
|
756
|
+
this.handingOff = true;
|
|
757
|
+
if (!this.ws) return Promise.resolve();
|
|
758
|
+
return new Promise((resolve) => {
|
|
759
|
+
let done = false;
|
|
760
|
+
const finish = () => {
|
|
761
|
+
if (done) return;
|
|
762
|
+
done = true;
|
|
763
|
+
clearTimeout(timer);
|
|
764
|
+
resolve();
|
|
765
|
+
};
|
|
766
|
+
this.handoffResolve = finish;
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
this.disable();
|
|
769
|
+
this.finishHandoff();
|
|
770
|
+
}, timeoutMs);
|
|
771
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
finishHandoff() {
|
|
775
|
+
const resolve = this.handoffResolve;
|
|
776
|
+
this.handoffResolve = null;
|
|
777
|
+
resolve?.();
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Grant one agent access to this machine (default-deny; user-initiated).
|
|
781
|
+
* `conversationId` is the conversation whose run asked (null when the grant
|
|
782
|
+
* is not answering an in-chat request) — the server uses it to wake the
|
|
783
|
+
* waiting agent.
|
|
784
|
+
*/
|
|
785
|
+
async grantAgent(agentId, conversationId) {
|
|
786
|
+
const auth = this.sessionAuth();
|
|
787
|
+
if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
|
|
788
|
+
await grantPortalAccess(auth, {
|
|
789
|
+
deviceId: await this.ensureDeviceId(),
|
|
790
|
+
agentId,
|
|
791
|
+
conversationId
|
|
792
|
+
});
|
|
793
|
+
this.granted.add(agentId);
|
|
794
|
+
this.emit();
|
|
795
|
+
}
|
|
796
|
+
/** Session-authed REST auth, or null when only a device token is held. */
|
|
797
|
+
sessionAuth() {
|
|
798
|
+
const { sessionToken } = this.opts.credentials();
|
|
799
|
+
return sessionToken ? {
|
|
800
|
+
appUrl: this.opts.appUrl,
|
|
801
|
+
sessionToken
|
|
802
|
+
} : null;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The bearer token for the portal WebSocket dial: minted from the session
|
|
806
|
+
* when one is held, otherwise the pre-minted device token as-is.
|
|
807
|
+
*/
|
|
808
|
+
async wsToken() {
|
|
809
|
+
const creds = this.opts.credentials();
|
|
810
|
+
if (creds.sessionToken) return mintPortalDeviceToken({
|
|
811
|
+
appUrl: this.opts.appUrl,
|
|
812
|
+
sessionToken: creds.sessionToken
|
|
813
|
+
});
|
|
814
|
+
if (creds.deviceToken) return creds.deviceToken;
|
|
815
|
+
throw new Error("no portal credentials (not signed in)");
|
|
816
|
+
}
|
|
817
|
+
setStatus(status, error = null) {
|
|
818
|
+
this.status = status;
|
|
819
|
+
this.error = error;
|
|
820
|
+
this.emit();
|
|
821
|
+
}
|
|
822
|
+
emit() {
|
|
823
|
+
this.opts.onState({
|
|
824
|
+
status: this.status,
|
|
825
|
+
machineName: this.machineName,
|
|
826
|
+
friendlyName: this.friendlyName,
|
|
827
|
+
error: this.error,
|
|
828
|
+
grantedAgentIds: [...this.granted]
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async connectLoop() {
|
|
832
|
+
if (!this.machineName) {
|
|
833
|
+
const identity = await resolveMachineIdentity(this.opts.persistedMachineName);
|
|
834
|
+
this.machineName = identity.machineName;
|
|
835
|
+
this.friendlyName = identity.friendlyName;
|
|
836
|
+
this.opts.onMachineName(identity.machineName, identity.machineNameSource);
|
|
837
|
+
}
|
|
838
|
+
let backoff = this.heartbeatConfig.initialBackoffMs;
|
|
839
|
+
while (this.enabled && !this.disposed && !this.handingOff) {
|
|
840
|
+
this.setStatus("connecting");
|
|
841
|
+
try {
|
|
842
|
+
const token = await this.wsToken();
|
|
843
|
+
if (await this.runConnection(token)) backoff = this.heartbeatConfig.initialBackoffMs;
|
|
844
|
+
else backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
|
|
845
|
+
} catch (err) {
|
|
846
|
+
if (!this.enabled || this.disposed || this.handingOff) break;
|
|
847
|
+
this.setStatus("error", errorMessage(err));
|
|
848
|
+
backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
|
|
849
|
+
}
|
|
850
|
+
if (!this.enabled || this.disposed || this.handingOff) break;
|
|
851
|
+
await this.backoffSleep(Math.random() * backoff);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
runConnection(token) {
|
|
855
|
+
return new Promise((resolve) => {
|
|
856
|
+
let settled = false;
|
|
857
|
+
let healthy = false;
|
|
858
|
+
const settle = () => {
|
|
859
|
+
if (settled) return;
|
|
860
|
+
settled = true;
|
|
861
|
+
resolve(healthy);
|
|
862
|
+
};
|
|
863
|
+
const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
|
|
864
|
+
headers: { authorization: `Bearer ${token}` },
|
|
865
|
+
maxPayload: MAX_WS_FRAME_BYTES
|
|
866
|
+
});
|
|
867
|
+
this.ws = ws;
|
|
868
|
+
const jobs = new JobManager({
|
|
869
|
+
resolveCwd: this.opts.resolveCwd,
|
|
870
|
+
tlsCertSource: this.opts.tlsCertSource,
|
|
871
|
+
send: (frame) => {
|
|
872
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
this.jobs = jobs;
|
|
876
|
+
let missed = 0;
|
|
877
|
+
const heartbeat = setInterval(() => {
|
|
878
|
+
if (ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) return;
|
|
879
|
+
if (missed >= this.heartbeatConfig.maxMissed) {
|
|
880
|
+
this.error = ws.readyState === WebSocket.CONNECTING ? "portal handshake stalled; reconnecting" : "portal connection went silent (no pong); reconnecting";
|
|
881
|
+
ws.terminate();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
missed += 1;
|
|
885
|
+
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
|
886
|
+
}, this.heartbeatConfig.intervalMs);
|
|
887
|
+
const markAlive = () => {
|
|
888
|
+
missed = 0;
|
|
889
|
+
healthy = true;
|
|
890
|
+
};
|
|
891
|
+
ws.on("open", () => {
|
|
892
|
+
missed = 0;
|
|
893
|
+
this.setStatus("connected");
|
|
894
|
+
this.syncDeviceState();
|
|
895
|
+
});
|
|
896
|
+
ws.on("pong", markAlive);
|
|
897
|
+
ws.on("ping", markAlive);
|
|
898
|
+
ws.on("message", (data, isBinary) => {
|
|
899
|
+
markAlive();
|
|
900
|
+
if (isBinary) jobs.handleFrame(toBuffer(data));
|
|
901
|
+
});
|
|
902
|
+
ws.on("error", (err) => {
|
|
903
|
+
this.error = errorMessage(err);
|
|
904
|
+
});
|
|
905
|
+
ws.on("close", () => {
|
|
906
|
+
clearInterval(heartbeat);
|
|
907
|
+
jobs.killAll();
|
|
908
|
+
if (this.jobs === jobs) this.jobs = null;
|
|
909
|
+
if (this.ws === ws) this.ws = null;
|
|
910
|
+
if (this.handingOff) this.finishHandoff();
|
|
911
|
+
settle();
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
async ensureDeviceId() {
|
|
916
|
+
if (this.deviceId) return this.deviceId;
|
|
917
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
918
|
+
await this.refreshDevice();
|
|
919
|
+
if (this.deviceId) return this.deviceId;
|
|
920
|
+
await sleep(300);
|
|
921
|
+
}
|
|
922
|
+
throw new Error("this machine is not connected yet");
|
|
923
|
+
}
|
|
924
|
+
async refreshDevice() {
|
|
925
|
+
const auth = this.sessionAuth();
|
|
926
|
+
if (!auth) return;
|
|
927
|
+
try {
|
|
928
|
+
const { devices } = await fetchPortalDevices(auth);
|
|
929
|
+
const mine = findThisDevice(devices, this.machineName);
|
|
930
|
+
if (!mine) return;
|
|
931
|
+
this.deviceId = mine.id;
|
|
932
|
+
this.granted = new Set(mine.grantedAgentIds);
|
|
933
|
+
this.emit();
|
|
934
|
+
} catch (_error) {}
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Post-connect device sync: read our row, then (once per process) copy any
|
|
938
|
+
* grants still hanging off the legacy `<host>-cli` device onto this one so
|
|
939
|
+
* agents the user already approved keep their access under the merged
|
|
940
|
+
* identity.
|
|
941
|
+
*/
|
|
942
|
+
async syncDeviceState() {
|
|
943
|
+
await this.refreshDevice();
|
|
944
|
+
if (this.legacyGrantsChecked) return;
|
|
945
|
+
const auth = this.sessionAuth();
|
|
946
|
+
if (!auth) return;
|
|
947
|
+
this.legacyGrantsChecked = true;
|
|
948
|
+
try {
|
|
949
|
+
if (await unifyLegacyCliGrants(auth, this.machineName) > 0) await this.refreshDevice();
|
|
950
|
+
} catch (_error) {}
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
function toBuffer(data) {
|
|
954
|
+
if (Buffer.isBuffer(data)) return data;
|
|
955
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
956
|
+
return Buffer.from(data);
|
|
957
|
+
}
|
|
958
|
+
function sleep(ms) {
|
|
959
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
//#endregion
|
|
963
|
+
export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
|