skydive-cli 0.5.0-beta.4 → 0.5.0-beta.41
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 +8 -0
- package/README.md +35 -4
- package/dist/js/api-DQCaztBg.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +655 -278
- package/dist/js/{boot-B_93T51X.mjs → boot-B1Y1D48W.mjs} +2980 -823
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-Cn2af31H.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/client-DCwVlAal.mjs +5 -0
- package/dist/js/{client-Dd5sMXPv.mjs → client-DMYwKALl.mjs} +348 -141
- package/dist/js/daemon-By-I_HVM.mjs +7 -0
- package/dist/js/{daemon-BYlIJN6x.mjs → daemon-D8WoX21I.mjs} +43 -7
- package/dist/js/{daemon-client-DPjmvIDE.mjs → daemon-client-Bp5DMeIi.mjs} +12 -8
- package/dist/js/daemon-client-_SVAskeO.mjs +8 -0
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-zwB55Bls.mjs +208 -0
- package/dist/js/{profiler-RmWjKIHD.mjs → install-BkMcGVYS.mjs} +529 -211
- 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-BAmEefvX.mjs} +3 -3
- package/dist/js/{print-CbayCa87.mjs → print-CwbdwCeQ.mjs} +213 -37
- package/dist/js/{print-share-uwUG16Ov.mjs → print-share-azWHVxpt.mjs} +8 -2
- 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-B5DVQ1LM.mjs +6 -0
- package/dist/js/{rest-BY2nADw5.mjs → rest-Bbe-RhMy.mjs} +105 -25
- 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/daemon-CBvFKDpy.mjs +0 -5
- package/dist/js/daemon-client-Z4Ce4zf-.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-UHH3mVBF.mjs} +0 -0
- /package/dist/js/{output-DYzzdXYV.mjs → output-wY0VQDea.mjs} +0 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, l as startTlsForward, n as fetchPortalDevices, r as findThisDevice } from "./api-DQCaztBg.mjs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
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";
|
|
7
9
|
|
|
8
10
|
//#region ../portal-daemon/src/machine.ts
|
|
11
|
+
const SCUTIL_TIMEOUT_MS = 2e3;
|
|
12
|
+
const SCUTIL_ATTEMPTS = 3;
|
|
9
13
|
/**
|
|
10
14
|
* Identity this machine registers under on the portal.
|
|
11
15
|
*
|
|
@@ -14,38 +18,74 @@ import { WebSocket } from "ws";
|
|
|
14
18
|
* row, so a grant applies to the machine no matter which surface the user
|
|
15
19
|
* shared from. The name must therefore match what the desktop app registers —
|
|
16
20
|
* on macOS that is `scutil --get LocalHostName` (the Rust client's own
|
|
17
|
-
* derivation, stabler than the network-assigned hostname)
|
|
18
|
-
*
|
|
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).
|
|
19
36
|
*
|
|
20
37
|
* Historically the CLI suffixed `-cli` to keep a device distinct from the
|
|
21
38
|
* desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
|
|
22
39
|
*/
|
|
23
|
-
async function resolveMachineIdentity() {
|
|
24
|
-
const
|
|
25
|
-
if (process.platform !== "darwin")
|
|
26
|
-
machineName
|
|
27
|
-
|
|
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"
|
|
28
60
|
};
|
|
29
|
-
const
|
|
61
|
+
const fallback = fallbackHost();
|
|
30
62
|
return {
|
|
31
|
-
machineName:
|
|
32
|
-
friendlyName:
|
|
63
|
+
machineName: fallback,
|
|
64
|
+
friendlyName: fallback,
|
|
65
|
+
machineNameSource: "hostname-fallback"
|
|
33
66
|
};
|
|
34
67
|
}
|
|
35
68
|
function fallbackHost() {
|
|
36
69
|
return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
37
70
|
}
|
|
38
71
|
function scutilRead(key) {
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
});
|
|
47
87
|
});
|
|
48
|
-
}
|
|
88
|
+
}
|
|
49
89
|
}
|
|
50
90
|
const INHERITED_ENV = [
|
|
51
91
|
"HOME",
|
|
@@ -134,6 +174,18 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
|
134
174
|
conversationId: z.string().nullable().optional()
|
|
135
175
|
}),
|
|
136
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
|
+
}),
|
|
137
189
|
z.object({ t: z.literal("pause") }),
|
|
138
190
|
z.object({ t: z.literal("resume") }),
|
|
139
191
|
z.object({ t: z.literal("cancel") }),
|
|
@@ -169,6 +221,128 @@ function decodeFrame(frame) {
|
|
|
169
221
|
};
|
|
170
222
|
}
|
|
171
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
|
+
|
|
172
346
|
//#endregion
|
|
173
347
|
//#region ../portal-daemon/src/exec.ts
|
|
174
348
|
/**
|
|
@@ -181,6 +355,7 @@ function decodeFrame(frame) {
|
|
|
181
355
|
*/
|
|
182
356
|
var JobManager = class {
|
|
183
357
|
jobs = /* @__PURE__ */ new Map();
|
|
358
|
+
reverse = /* @__PURE__ */ new Map();
|
|
184
359
|
constructor(opts) {
|
|
185
360
|
this.opts = opts;
|
|
186
361
|
}
|
|
@@ -200,12 +375,35 @@ var JobManager = class {
|
|
|
200
375
|
job.child.kill("SIGKILL");
|
|
201
376
|
}
|
|
202
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();
|
|
203
383
|
}
|
|
204
384
|
handleCtrl(id, msg) {
|
|
205
385
|
switch (msg.t) {
|
|
206
386
|
case "open":
|
|
207
387
|
this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
|
|
208
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
|
+
}
|
|
209
407
|
case "stdin_eof":
|
|
210
408
|
this.jobs.get(id)?.child.stdin.end();
|
|
211
409
|
return;
|
|
@@ -217,12 +415,52 @@ var JobManager = class {
|
|
|
217
415
|
return;
|
|
218
416
|
case "cancel":
|
|
219
417
|
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
418
|
+
this.stopReverse(id);
|
|
220
419
|
return;
|
|
221
420
|
case "close":
|
|
222
|
-
case "error":
|
|
421
|
+
case "error":
|
|
422
|
+
case "reverse_listening": return;
|
|
223
423
|
default: return msg;
|
|
224
424
|
}
|
|
225
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
|
+
}
|
|
226
464
|
setPaused(id, paused) {
|
|
227
465
|
const job = this.jobs.get(id);
|
|
228
466
|
if (!job) return;
|
|
@@ -296,115 +534,16 @@ var JobManager = class {
|
|
|
296
534
|
}
|
|
297
535
|
};
|
|
298
536
|
|
|
299
|
-
//#endregion
|
|
300
|
-
//#region ../portal-daemon/src/api.ts
|
|
301
|
-
/**
|
|
302
|
-
* The portal's session-authed REST surface, shared by `PortalClient` (the
|
|
303
|
-
* TUI/`portal open` connection) and the `skydive portal` management
|
|
304
|
-
* commands, so the endpoint contracts and response schemas live in exactly
|
|
305
|
-
* one place.
|
|
306
|
-
*/
|
|
307
|
-
const deviceSchema = z.object({
|
|
308
|
-
id: z.string(),
|
|
309
|
-
machineName: z.string(),
|
|
310
|
-
friendlyName: z.string(),
|
|
311
|
-
connected: z.boolean(),
|
|
312
|
-
lastSeen: z.string().nullable(),
|
|
313
|
-
grantedAgentIds: z.array(z.string())
|
|
314
|
-
});
|
|
315
|
-
const devicesResponseSchema = z.object({
|
|
316
|
-
devices: z.array(deviceSchema),
|
|
317
|
-
agents: z.array(z.object({
|
|
318
|
-
id: z.string(),
|
|
319
|
-
name: z.string()
|
|
320
|
-
}))
|
|
321
|
-
});
|
|
322
|
-
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
323
|
-
async function portalFetch(auth, path, init) {
|
|
324
|
-
const res = await fetch(`${auth.appUrl}${path}`, {
|
|
325
|
-
method: init.method,
|
|
326
|
-
headers: {
|
|
327
|
-
authorization: `Bearer ${auth.sessionToken}`,
|
|
328
|
-
accept: "application/json",
|
|
329
|
-
...init.body ? { "content-type": "application/json" } : {}
|
|
330
|
-
},
|
|
331
|
-
...init.body ? { body: init.body } : {}
|
|
332
|
-
});
|
|
333
|
-
if (!res.ok) {
|
|
334
|
-
const body = await res.text().catch(() => "");
|
|
335
|
-
throw new HttpError(res.status, body);
|
|
336
|
-
}
|
|
337
|
-
return res.json();
|
|
338
|
-
}
|
|
339
|
-
async function fetchPortalDevices(auth) {
|
|
340
|
-
const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
|
|
341
|
-
return devicesResponseSchema.parse(json);
|
|
342
|
-
}
|
|
343
|
-
const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
|
|
344
|
-
/**
|
|
345
|
-
* Register this machine's device row without connecting. Connecting registers
|
|
346
|
-
* as a side effect; this covers granting an agent on a machine that has never
|
|
347
|
-
* shared yet (the grant references the device row).
|
|
348
|
-
*/
|
|
349
|
-
async function registerPortalDevice(auth, { machineName, friendlyName }) {
|
|
350
|
-
const json = await portalFetch(auth, "/api/v1/portal/devices", {
|
|
351
|
-
method: "POST",
|
|
352
|
-
body: JSON.stringify({
|
|
353
|
-
machineName,
|
|
354
|
-
friendlyName
|
|
355
|
-
})
|
|
356
|
-
});
|
|
357
|
-
return registerResponseSchema.parse(json).device;
|
|
358
|
-
}
|
|
359
|
-
/** Short-lived token the machine presents when dialing the portal WebSocket. */
|
|
360
|
-
async function mintPortalDeviceToken(auth) {
|
|
361
|
-
const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
|
|
362
|
-
return deviceTokenSchema.parse(json).token;
|
|
363
|
-
}
|
|
364
|
-
async function grantPortalAccess(auth, { deviceId, agentId }) {
|
|
365
|
-
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
366
|
-
method: "POST",
|
|
367
|
-
body: JSON.stringify({ agentId })
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
async function revokePortalAccess(auth, { deviceId, agentId }) {
|
|
371
|
-
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* The device row for a given machine identity. Matching is by `machineName`
|
|
375
|
-
* equality — the stable handle the machine registers under, not the display
|
|
376
|
-
* label.
|
|
377
|
-
*/
|
|
378
|
-
function findThisDevice(devices, machineName) {
|
|
379
|
-
return devices.find((device) => device.machineName === machineName) ?? null;
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* One-time grant migration onto the merged device. Earlier CLI builds
|
|
383
|
-
* registered a separate `<machineName>-cli` device, so a user's existing
|
|
384
|
-
* approvals hang off that row; the merged device would start with zero grants
|
|
385
|
-
* and every already-authorized agent would ask again. Copy any grant the
|
|
386
|
-
* merged device is missing (the grant endpoint upserts, so re-runs are
|
|
387
|
-
* no-ops). The legacy row is left in place — an old CLI build may still
|
|
388
|
-
* connect under it. Returns how many grants were copied.
|
|
389
|
-
*/
|
|
390
|
-
async function unifyLegacyCliGrants(auth, machineName) {
|
|
391
|
-
const { devices } = await fetchPortalDevices(auth);
|
|
392
|
-
const merged = findThisDevice(devices, machineName);
|
|
393
|
-
const legacy = findThisDevice(devices, `${machineName}-cli`);
|
|
394
|
-
if (!merged || !legacy) return 0;
|
|
395
|
-
const have = new Set(merged.grantedAgentIds);
|
|
396
|
-
const missing = legacy.grantedAgentIds.filter((id) => !have.has(id));
|
|
397
|
-
for (const agentId of missing) await grantPortalAccess(auth, {
|
|
398
|
-
deviceId: merged.id,
|
|
399
|
-
agentId
|
|
400
|
-
});
|
|
401
|
-
return missing.length;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
537
|
//#endregion
|
|
405
538
|
//#region ../portal-daemon/src/client.ts
|
|
406
|
-
const
|
|
407
|
-
|
|
539
|
+
const DEFAULT_HEARTBEAT = {
|
|
540
|
+
intervalMs: 15e3,
|
|
541
|
+
maxMissed: 2,
|
|
542
|
+
wakeTickMs: 2e3,
|
|
543
|
+
wakeGapThresholdMs: 1e4,
|
|
544
|
+
initialBackoffMs: 500,
|
|
545
|
+
maxBackoffMs: 1e4
|
|
546
|
+
};
|
|
408
547
|
/**
|
|
409
548
|
* Shares the local machine with agents over the portal: dials OUT to the api's
|
|
410
549
|
* desktop-portal WebSocket (authenticating with a short-lived device token
|
|
@@ -427,8 +566,38 @@ var PortalClient = class {
|
|
|
427
566
|
machineName = "";
|
|
428
567
|
friendlyName = "";
|
|
429
568
|
legacyGrantsChecked = false;
|
|
569
|
+
wakeWatch = null;
|
|
570
|
+
lastTick = Date.now();
|
|
571
|
+
heartbeatConfig;
|
|
430
572
|
constructor(opts) {
|
|
431
573
|
this.opts = opts;
|
|
574
|
+
this.heartbeatConfig = {
|
|
575
|
+
...DEFAULT_HEARTBEAT,
|
|
576
|
+
...opts.heartbeat
|
|
577
|
+
};
|
|
578
|
+
this.startWakeWatch();
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Detect wake-from-sleep without an Electron/`powerMonitor` dependency (the
|
|
582
|
+
* daemon also runs headless under the CLI). A repeating timer that should
|
|
583
|
+
* fire every WAKE_TICK_MS; when the actual gap since the last tick far
|
|
584
|
+
* exceeds that, the process was suspended (macOS sleep, laptop lid) and the
|
|
585
|
+
* current socket — if any — is a zombie against a server that has already
|
|
586
|
+
* reaped our presence. Terminate it now so `connectLoop` redials at once
|
|
587
|
+
* instead of waiting out the heartbeat deadline. Cheap: one no-op timer.
|
|
588
|
+
*/
|
|
589
|
+
startWakeWatch() {
|
|
590
|
+
this.lastTick = Date.now();
|
|
591
|
+
this.wakeWatch = setInterval(() => {
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
const gap = now - this.lastTick;
|
|
594
|
+
this.lastTick = now;
|
|
595
|
+
if (gap > this.heartbeatConfig.wakeGapThresholdMs && this.ws) {
|
|
596
|
+
this.error = "woke from sleep; reconnecting portal";
|
|
597
|
+
this.ws.terminate();
|
|
598
|
+
}
|
|
599
|
+
}, this.heartbeatConfig.wakeTickMs);
|
|
600
|
+
this.wakeWatch.unref?.();
|
|
432
601
|
}
|
|
433
602
|
isEnabled() {
|
|
434
603
|
return this.enabled;
|
|
@@ -464,18 +633,28 @@ var PortalClient = class {
|
|
|
464
633
|
dispose() {
|
|
465
634
|
this.disposed = true;
|
|
466
635
|
this.enabled = false;
|
|
636
|
+
if (this.wakeWatch) {
|
|
637
|
+
clearInterval(this.wakeWatch);
|
|
638
|
+
this.wakeWatch = null;
|
|
639
|
+
}
|
|
467
640
|
this.jobs?.killAll();
|
|
468
641
|
this.jobs = null;
|
|
469
642
|
this.ws?.close();
|
|
470
643
|
this.ws = null;
|
|
471
644
|
}
|
|
472
|
-
/**
|
|
473
|
-
|
|
645
|
+
/**
|
|
646
|
+
* Grant one agent access to this machine (default-deny; user-initiated).
|
|
647
|
+
* `conversationId` is the conversation whose run asked (null when the grant
|
|
648
|
+
* is not answering an in-chat request) — the server uses it to wake the
|
|
649
|
+
* waiting agent.
|
|
650
|
+
*/
|
|
651
|
+
async grantAgent(agentId, conversationId) {
|
|
474
652
|
const auth = this.sessionAuth();
|
|
475
653
|
if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
|
|
476
654
|
await grantPortalAccess(auth, {
|
|
477
655
|
deviceId: await this.ensureDeviceId(),
|
|
478
|
-
agentId
|
|
656
|
+
agentId,
|
|
657
|
+
conversationId
|
|
479
658
|
});
|
|
480
659
|
this.granted.add(agentId);
|
|
481
660
|
this.emit();
|
|
@@ -517,28 +696,36 @@ var PortalClient = class {
|
|
|
517
696
|
}
|
|
518
697
|
async connectLoop() {
|
|
519
698
|
if (!this.machineName) {
|
|
520
|
-
const identity = await resolveMachineIdentity();
|
|
699
|
+
const identity = await resolveMachineIdentity(this.opts.persistedMachineName);
|
|
521
700
|
this.machineName = identity.machineName;
|
|
522
701
|
this.friendlyName = identity.friendlyName;
|
|
702
|
+
this.opts.onMachineName(identity.machineName, identity.machineNameSource);
|
|
523
703
|
}
|
|
524
|
-
let backoff =
|
|
704
|
+
let backoff = this.heartbeatConfig.initialBackoffMs;
|
|
525
705
|
while (this.enabled && !this.disposed) {
|
|
526
706
|
this.setStatus("connecting");
|
|
527
707
|
try {
|
|
528
708
|
const token = await this.wsToken();
|
|
529
|
-
await this.runConnection(token);
|
|
530
|
-
backoff =
|
|
709
|
+
if (await this.runConnection(token)) backoff = this.heartbeatConfig.initialBackoffMs;
|
|
710
|
+
else backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
|
|
531
711
|
} catch (err) {
|
|
532
712
|
if (!this.enabled || this.disposed) break;
|
|
533
713
|
this.setStatus("error", errorMessage(err));
|
|
714
|
+
backoff = Math.min(backoff * 2, this.heartbeatConfig.maxBackoffMs);
|
|
534
715
|
}
|
|
535
716
|
if (!this.enabled || this.disposed) break;
|
|
536
|
-
await sleep(backoff);
|
|
537
|
-
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
717
|
+
await sleep(Math.random() * backoff);
|
|
538
718
|
}
|
|
539
719
|
}
|
|
540
720
|
runConnection(token) {
|
|
541
721
|
return new Promise((resolve) => {
|
|
722
|
+
let settled = false;
|
|
723
|
+
let healthy = false;
|
|
724
|
+
const settle = () => {
|
|
725
|
+
if (settled) return;
|
|
726
|
+
settled = true;
|
|
727
|
+
resolve(healthy);
|
|
728
|
+
};
|
|
542
729
|
const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
|
|
543
730
|
headers: { authorization: `Bearer ${token}` },
|
|
544
731
|
maxPayload: MAX_WS_FRAME_BYTES
|
|
@@ -546,26 +733,46 @@ var PortalClient = class {
|
|
|
546
733
|
this.ws = ws;
|
|
547
734
|
const jobs = new JobManager({
|
|
548
735
|
resolveCwd: this.opts.resolveCwd,
|
|
736
|
+
tlsCertSource: this.opts.tlsCertSource,
|
|
549
737
|
send: (frame) => {
|
|
550
738
|
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
551
739
|
}
|
|
552
740
|
});
|
|
553
741
|
this.jobs = jobs;
|
|
742
|
+
let missed = 0;
|
|
743
|
+
const heartbeat = setInterval(() => {
|
|
744
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
745
|
+
if (missed >= this.heartbeatConfig.maxMissed) {
|
|
746
|
+
this.error = "portal connection went silent (no pong); reconnecting";
|
|
747
|
+
ws.terminate();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
missed += 1;
|
|
751
|
+
ws.ping();
|
|
752
|
+
}, this.heartbeatConfig.intervalMs);
|
|
753
|
+
const markAlive = () => {
|
|
754
|
+
missed = 0;
|
|
755
|
+
healthy = true;
|
|
756
|
+
};
|
|
554
757
|
ws.on("open", () => {
|
|
555
758
|
this.setStatus("connected");
|
|
556
759
|
this.syncDeviceState();
|
|
557
760
|
});
|
|
761
|
+
ws.on("pong", markAlive);
|
|
762
|
+
ws.on("ping", markAlive);
|
|
558
763
|
ws.on("message", (data, isBinary) => {
|
|
764
|
+
markAlive();
|
|
559
765
|
if (isBinary) jobs.handleFrame(toBuffer(data));
|
|
560
766
|
});
|
|
561
767
|
ws.on("error", (err) => {
|
|
562
768
|
this.error = errorMessage(err);
|
|
563
769
|
});
|
|
564
770
|
ws.on("close", () => {
|
|
771
|
+
clearInterval(heartbeat);
|
|
565
772
|
jobs.killAll();
|
|
566
773
|
if (this.jobs === jobs) this.jobs = null;
|
|
567
774
|
if (this.ws === ws) this.ws = null;
|
|
568
|
-
|
|
775
|
+
settle();
|
|
569
776
|
});
|
|
570
777
|
});
|
|
571
778
|
}
|
|
@@ -617,4 +824,4 @@ function sleep(ms) {
|
|
|
617
824
|
}
|
|
618
825
|
|
|
619
826
|
//#endregion
|
|
620
|
-
export {
|
|
827
|
+
export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "./client-DMYwKALl.mjs";
|
|
3
|
+
import { a as runPortalDaemon, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-D8WoX21I.mjs";
|
|
4
|
+
import "./tls-cert-CV-pwxVN.mjs";
|
|
5
|
+
import "./api-DQCaztBg.mjs";
|
|
6
|
+
|
|
7
|
+
export { runPortalDaemon };
|