skydive-cli 0.5.0-beta.4 → 0.5.0-beta.44

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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +35 -4
  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-B_93T51X.mjs → boot-ClKjP9Mn.mjs} +3263 -755
  7. package/dist/js/chunk-BbwQpWto.mjs +33 -0
  8. package/dist/js/{client-Dd5sMXPv.mjs → client-BnpgrtCC.mjs} +348 -141
  9. package/dist/js/{client-Cn2af31H.mjs → client-Btq6bMzX.mjs} +108 -2
  10. package/dist/js/client-Cga4PJUc.mjs +5 -0
  11. package/dist/js/daemon-client-D5YCk5Hc.mjs +8 -0
  12. package/dist/js/{daemon-client-DPjmvIDE.mjs → daemon-client-KKNss0NK.mjs} +12 -8
  13. package/dist/js/daemon-mLypjXdu.mjs +7 -0
  14. package/dist/js/{daemon-BYlIJN6x.mjs → daemon-w8vtZ81t.mjs} +43 -7
  15. package/dist/js/dist-CRtjM7ba.mjs +1750 -0
  16. package/dist/js/forward-Ct8Vd2WW.mjs +208 -0
  17. package/dist/js/{profiler-RmWjKIHD.mjs → install-DrtNldUN.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-Dc-xRapT.mjs} +3 -3
  21. package/dist/js/{print-CbayCa87.mjs → print-Ds16-Q4O.mjs} +289 -37
  22. package/dist/js/{print-share-uwUG16Ov.mjs → print-share-Df6eAG12.mjs} +8 -2
  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-Do3mPVpC.mjs +6 -0
  26. package/dist/js/{rest-BY2nADw5.mjs → rest-DpsJUYTL.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/daemon-CBvFKDpy.mjs +0 -5
  32. package/dist/js/daemon-client-Z4Ce4zf-.mjs +0 -6
  33. package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
  34. package/dist/js/rest-DADJh0bi.mjs +0 -6
  35. /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
  36. /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
  37. /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+
4
+ //#region \0rolldown/runtime.js
5
+ var __create = Object.create;
6
+ var __defProp = Object.defineProperty;
7
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
+ var __getOwnPropNames = Object.getOwnPropertyNames;
9
+ var __getProtoOf = Object.getPrototypeOf;
10
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
11
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
15
+ key = keys[i];
16
+ if (!__hasOwnProp.call(to, key) && key !== except) {
17
+ __defProp(to, key, {
18
+ get: ((k) => from[k]).bind(null, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ }
23
+ }
24
+ return to;
25
+ };
26
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
27
+ value: mod,
28
+ enumerable: true
29
+ }) : target, mod));
30
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
31
+
32
+ //#endregion
33
+ export { __require as n, __toESM as r, __commonJSMin as t };
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { t as HttpError } from "./http-error-DzyrsLAZ.mjs";
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
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
- import { execFile, spawn } from "node:child_process";
6
- import { WebSocket } from "ws";
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), with the plain
18
- * hostname as the fallback for other platforms or a failed read.
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 fallback = fallbackHost();
25
- if (process.platform !== "darwin") return {
26
- machineName: fallback,
27
- friendlyName: fallback
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 local = await scutilRead("LocalHostName") ?? fallback;
61
+ const fallback = fallbackHost();
30
62
  return {
31
- machineName: local,
32
- friendlyName: await scutilRead("ComputerName") ?? local
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 new Promise((resolve) => {
40
- execFile("/usr/sbin/scutil", ["--get", key], { timeout: 2e3 }, (error, stdout) => {
41
- if (error) {
42
- resolve(null);
43
- return;
44
- }
45
- const value = stdout.trim();
46
- resolve(value.length > 0 ? value : null);
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": return;
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 INITIAL_BACKOFF_MS = 500;
407
- const MAX_BACKOFF_MS = 1e4;
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
- /** Grant one agent access to this machine (default-deny; user-initiated). */
473
- async grantAgent(agentId) {
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 = INITIAL_BACKOFF_MS;
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 = INITIAL_BACKOFF_MS;
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
- resolve();
775
+ settle();
569
776
  });
570
777
  });
571
778
  }
@@ -617,4 +824,4 @@ function sleep(ms) {
617
824
  }
618
825
 
619
826
  //#endregion
620
- export { registerPortalDevice as a, resolveMachineIdentity as c, grantPortalAccess as i, fetchPortalDevices as n, revokePortalAccess as o, findThisDevice as r, isRecord as s, PortalClient as t };
827
+ export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };