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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +60 -12
  3. package/dist/js/api-TwLD7ibI.mjs +315 -0
  4. package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
  5. package/dist/js/bin.mjs +658 -279
  6. package/dist/js/{boot-Brwh0it_.mjs → boot-D_VFIRJj.mjs} +3556 -995
  7. package/dist/js/chunk-BbwQpWto.mjs +33 -0
  8. package/dist/js/{client-Cn2af31H.mjs → client-Btq6bMzX.mjs} +108 -2
  9. package/dist/js/client-CkPQG8M1.mjs +5 -0
  10. package/dist/js/client-aFwHzCPG.mjs +963 -0
  11. package/dist/js/daemon-CORAITjj.mjs +7 -0
  12. package/dist/js/{daemon-client-DcuD4v12.mjs → daemon-client-Cfi66Xy9.mjs} +12 -8
  13. package/dist/js/daemon-client-DJPW9cRp.mjs +8 -0
  14. package/dist/js/{daemon-BhArnzeW.mjs → daemon-uzpOdRPL.mjs} +135 -10
  15. package/dist/js/dist-CRtjM7ba.mjs +1750 -0
  16. package/dist/js/forward-Ct8Vd2WW.mjs +208 -0
  17. package/dist/js/{profiler-LLaIFZgn.mjs → install-BBplm-Zp.mjs} +529 -215
  18. package/dist/js/launcher.mjs +49 -0
  19. package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
  20. package/dist/js/{print-ba_0hiV9.mjs → print-Cuao6LN6.mjs} +3 -3
  21. package/dist/js/{print-CbayCa87.mjs → print-DR6Gas-M.mjs} +291 -39
  22. package/dist/js/{print-share-uwUG16Ov.mjs → print-share-BypBUrNh.mjs} +9 -3
  23. package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
  24. package/dist/js/{raw-pty-B6mAroiI.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
  25. package/dist/js/rest-CJkBP2Jz.mjs +6 -0
  26. package/dist/js/{rest-BY2nADw5.mjs → rest-DkuT5_oX.mjs} +117 -28
  27. package/dist/js/tls-cert-CLgSQALB.mjs +4 -0
  28. package/dist/js/tls-cert-CV-pwxVN.mjs +67 -0
  29. package/package.json +11 -4
  30. package/dist/js/client-DZstLQ_1.mjs +0 -4
  31. package/dist/js/client-Dd5sMXPv.mjs +0 -620
  32. package/dist/js/daemon-CjzrUvXx.mjs +0 -5
  33. package/dist/js/daemon-client-D8bNXTxu.mjs +0 -6
  34. package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
  35. package/dist/js/rest-DADJh0bi.mjs +0 -6
  36. /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
  37. /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
  38. /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 };