skydive-cli 0.4.1-beta.6 → 0.5.0-beta.10

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.
@@ -1,9 +1,10 @@
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, n as fetchPortalDevices, r as findThisDevice } from "./api-DG5W6iwx.mjs";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
5
  import { execFile, spawn } from "node:child_process";
6
- import { WebSocket } from "ws";
6
+ import net from "node:net";
7
+ import { WebSocket, createWebSocketStream } from "ws";
7
8
 
8
9
  //#region ../portal-daemon/src/machine.ts
9
10
  /**
@@ -58,12 +59,18 @@ const INHERITED_ENV = [
58
59
  "TERM",
59
60
  "PATH"
60
61
  ];
62
+ const DARWIN_PATH_PREPEND = ["/opt/homebrew/bin", "/usr/local/bin"];
61
63
  function buildEnv(extra) {
62
64
  const env = {};
63
65
  for (const key of INHERITED_ENV) {
64
66
  const value = process.env[key];
65
67
  if (value !== void 0) env[key] = value;
66
68
  }
69
+ if (process.platform === "darwin") {
70
+ const current = env.PATH ? env.PATH.split(":") : [];
71
+ const missing = DARWIN_PATH_PREPEND.filter((dir) => !current.includes(dir));
72
+ if (missing.length > 0) env.PATH = [...missing, ...current].join(":");
73
+ }
67
74
  if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
68
75
  return env;
69
76
  }
@@ -128,6 +135,18 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
128
135
  conversationId: z.string().nullable().optional()
129
136
  }),
130
137
  z.object({ t: z.literal("stdin_eof") }),
138
+ z.object({
139
+ t: z.literal("reverse_listen"),
140
+ listenPort: z.number().int().positive(),
141
+ targetPort: z.number().int().positive(),
142
+ dialOrigin: z.string().min(1),
143
+ token: z.string().min(1)
144
+ }),
145
+ z.object({ t: z.literal("reverse_listening") }),
146
+ z.object({
147
+ t: z.literal("reverse_token"),
148
+ token: z.string().min(1)
149
+ }),
131
150
  z.object({ t: z.literal("pause") }),
132
151
  z.object({ t: z.literal("resume") }),
133
152
  z.object({ t: z.literal("cancel") }),
@@ -163,6 +182,62 @@ function decodeFrame(frame) {
163
182
  };
164
183
  }
165
184
 
185
+ //#endregion
186
+ //#region ../portal-daemon/src/reverse-listener.ts
187
+ /**
188
+ * The desktop half of an agent-initiated expose tunnel (`platform portal
189
+ * expose`): listen on the local loopback and pipe each accepted TCP
190
+ * connection to the agent sandbox's daemon (`/portal/tcp`) through the
191
+ * agent-webserver edge Worker — the same per-connection dial-and-pipe as the
192
+ * user-initiated `skydive portal forward`, just started from a directive
193
+ * instead of a terminal. Tunnel bytes never touch the directive stream that
194
+ * created this listener; it only anchors the lifetime and carries the token.
195
+ *
196
+ * `target()` is read per connection so a token refresh (or any future
197
+ * retarget) applies to the next dial without touching established pipes.
198
+ */
199
+ var ReverseListener = class {
200
+ server = null;
201
+ conns = /* @__PURE__ */ new Set();
202
+ constructor(opts) {
203
+ this.opts = opts;
204
+ }
205
+ start() {
206
+ const server = net.createServer((sock) => {
207
+ this.conns.add(sock);
208
+ sock.once("close", () => this.conns.delete(sock));
209
+ sock.pause();
210
+ const { dialOrigin, targetPort, token } = this.opts.target();
211
+ const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
212
+ ws.on("open", () => {
213
+ const stream = createWebSocketStream(ws);
214
+ stream.on("error", () => sock.destroy());
215
+ sock.on("error", () => stream.destroy());
216
+ sock.pipe(stream).pipe(sock);
217
+ sock.resume();
218
+ });
219
+ ws.on("error", (err) => {
220
+ this.opts.log(`expose: tunnel connect failed: ${err.message}`);
221
+ sock.destroy();
222
+ });
223
+ ws.on("unexpected-response", (_req, res) => {
224
+ this.opts.log(`expose: tunnel rejected (HTTP ${res.statusCode ?? "?"})`);
225
+ res.destroy();
226
+ sock.destroy();
227
+ });
228
+ });
229
+ server.once("error", (err) => this.opts.onListening(err));
230
+ server.listen(this.opts.listenPort, "127.0.0.1", () => this.opts.onListening(null));
231
+ this.server = server;
232
+ }
233
+ stop() {
234
+ this.server?.close();
235
+ this.server = null;
236
+ for (const sock of this.conns) sock.destroy();
237
+ this.conns.clear();
238
+ }
239
+ };
240
+
166
241
  //#endregion
167
242
  //#region ../portal-daemon/src/exec.ts
168
243
  /**
@@ -175,6 +250,7 @@ function decodeFrame(frame) {
175
250
  */
176
251
  var JobManager = class {
177
252
  jobs = /* @__PURE__ */ new Map();
253
+ reverse = /* @__PURE__ */ new Map();
178
254
  constructor(opts) {
179
255
  this.opts = opts;
180
256
  }
@@ -194,12 +270,35 @@ var JobManager = class {
194
270
  job.child.kill("SIGKILL");
195
271
  }
196
272
  this.jobs.clear();
273
+ for (const rev of this.reverse.values()) {
274
+ rev.settled = true;
275
+ rev.listener.stop();
276
+ }
277
+ this.reverse.clear();
197
278
  }
198
279
  handleCtrl(id, msg) {
199
280
  switch (msg.t) {
200
281
  case "open":
201
282
  this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
202
283
  return;
284
+ case "reverse_listen":
285
+ this.startReverse(id, {
286
+ listenPort: msg.listenPort,
287
+ target: {
288
+ dialOrigin: msg.dialOrigin,
289
+ targetPort: msg.targetPort,
290
+ token: msg.token
291
+ }
292
+ });
293
+ return;
294
+ case "reverse_token": {
295
+ const rev = this.reverse.get(id);
296
+ if (rev) rev.target = {
297
+ ...rev.target,
298
+ token: msg.token
299
+ };
300
+ return;
301
+ }
203
302
  case "stdin_eof":
204
303
  this.jobs.get(id)?.child.stdin.end();
205
304
  return;
@@ -211,12 +310,51 @@ var JobManager = class {
211
310
  return;
212
311
  case "cancel":
213
312
  this.jobs.get(id)?.child.kill("SIGKILL");
313
+ this.stopReverse(id);
214
314
  return;
215
315
  case "close":
216
- case "error": return;
316
+ case "error":
317
+ case "reverse_listening": return;
217
318
  default: return msg;
218
319
  }
219
320
  }
321
+ startReverse(id, { listenPort, target }) {
322
+ const rev = {
323
+ settled: false,
324
+ target,
325
+ seq: 0,
326
+ listener: new ReverseListener({
327
+ listenPort,
328
+ target: () => rev.target,
329
+ log: (msg) => {
330
+ if (rev.settled) return;
331
+ this.opts.send(encodeData(id, STREAM.stderr, rev.seq, Buffer.from(msg)));
332
+ rev.seq = rev.seq + 1 >>> 0;
333
+ },
334
+ onListening: (err) => {
335
+ if (err) {
336
+ rev.settled = true;
337
+ this.reverse.delete(id);
338
+ this.opts.send(encodeCtrl(id, {
339
+ t: "error",
340
+ message: `reverse listen failed: ${err.message}`
341
+ }));
342
+ return;
343
+ }
344
+ this.opts.send(encodeCtrl(id, { t: "reverse_listening" }));
345
+ }
346
+ })
347
+ };
348
+ this.reverse.set(id, rev);
349
+ rev.listener.start();
350
+ }
351
+ stopReverse(id) {
352
+ const rev = this.reverse.get(id);
353
+ if (!rev) return;
354
+ rev.settled = true;
355
+ this.reverse.delete(id);
356
+ rev.listener.stop();
357
+ }
220
358
  setPaused(id, paused) {
221
359
  const job = this.jobs.get(id);
222
360
  if (!job) return;
@@ -290,111 +428,6 @@ var JobManager = class {
290
428
  }
291
429
  };
292
430
 
293
- //#endregion
294
- //#region ../portal-daemon/src/api.ts
295
- /**
296
- * The portal's session-authed REST surface, shared by `PortalClient` (the
297
- * TUI/`portal open` connection) and the `skydive portal` management
298
- * commands, so the endpoint contracts and response schemas live in exactly
299
- * one place.
300
- */
301
- const deviceSchema = z.object({
302
- id: z.string(),
303
- machineName: z.string(),
304
- friendlyName: z.string(),
305
- connected: z.boolean(),
306
- lastSeen: z.string().nullable(),
307
- grantedAgentIds: z.array(z.string())
308
- });
309
- const devicesResponseSchema = z.object({
310
- devices: z.array(deviceSchema),
311
- agents: z.array(z.object({
312
- id: z.string(),
313
- name: z.string()
314
- }))
315
- });
316
- const deviceTokenSchema = z.object({ token: z.string().min(1) });
317
- async function portalFetch(auth, path, init) {
318
- const res = await fetch(`${auth.appUrl}${path}`, {
319
- method: init.method,
320
- headers: {
321
- authorization: `Bearer ${auth.sessionToken}`,
322
- accept: "application/json",
323
- ...init.body ? { "content-type": "application/json" } : {}
324
- },
325
- ...init.body ? { body: init.body } : {}
326
- });
327
- if (!res.ok) {
328
- const body = await res.text().catch(() => "");
329
- throw new HttpError(res.status, body);
330
- }
331
- return res.json();
332
- }
333
- async function fetchPortalDevices(auth) {
334
- const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
335
- return devicesResponseSchema.parse(json);
336
- }
337
- const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
338
- /**
339
- * Register this machine's device row without connecting. Connecting registers
340
- * as a side effect; this covers granting an agent on a machine that has never
341
- * shared yet (the grant references the device row).
342
- */
343
- async function registerPortalDevice(auth, { machineName, friendlyName }) {
344
- const json = await portalFetch(auth, "/api/v1/portal/devices", {
345
- method: "POST",
346
- body: JSON.stringify({
347
- machineName,
348
- friendlyName
349
- })
350
- });
351
- return registerResponseSchema.parse(json).device;
352
- }
353
- /** Short-lived token the machine presents when dialing the portal WebSocket. */
354
- async function mintPortalDeviceToken(auth) {
355
- const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
356
- return deviceTokenSchema.parse(json).token;
357
- }
358
- async function grantPortalAccess(auth, { deviceId, agentId }) {
359
- await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
360
- method: "POST",
361
- body: JSON.stringify({ agentId })
362
- });
363
- }
364
- async function revokePortalAccess(auth, { deviceId, agentId }) {
365
- await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
366
- }
367
- /**
368
- * The device row for a given machine identity. Matching is by `machineName`
369
- * equality — the stable handle the machine registers under, not the display
370
- * label.
371
- */
372
- function findThisDevice(devices, machineName) {
373
- return devices.find((device) => device.machineName === machineName) ?? null;
374
- }
375
- /**
376
- * One-time grant migration onto the merged device. Earlier CLI builds
377
- * registered a separate `<machineName>-cli` device, so a user's existing
378
- * approvals hang off that row; the merged device would start with zero grants
379
- * and every already-authorized agent would ask again. Copy any grant the
380
- * merged device is missing (the grant endpoint upserts, so re-runs are
381
- * no-ops). The legacy row is left in place — an old CLI build may still
382
- * connect under it. Returns how many grants were copied.
383
- */
384
- async function unifyLegacyCliGrants(auth, machineName) {
385
- const { devices } = await fetchPortalDevices(auth);
386
- const merged = findThisDevice(devices, machineName);
387
- const legacy = findThisDevice(devices, `${machineName}-cli`);
388
- if (!merged || !legacy) return 0;
389
- const have = new Set(merged.grantedAgentIds);
390
- const missing = legacy.grantedAgentIds.filter((id) => !have.has(id));
391
- for (const agentId of missing) await grantPortalAccess(auth, {
392
- deviceId: merged.id,
393
- agentId
394
- });
395
- return missing.length;
396
- }
397
-
398
431
  //#endregion
399
432
  //#region ../portal-daemon/src/client.ts
400
433
  const INITIAL_BACKOFF_MS = 500;
@@ -430,6 +463,10 @@ var PortalClient = class {
430
463
  isGranted(agentId) {
431
464
  return this.granted.has(agentId);
432
465
  }
466
+ /** Agents currently authorized on this machine (last known server state). */
467
+ grantedAgentIds() {
468
+ return [...this.granted];
469
+ }
433
470
  enable() {
434
471
  if (this.enabled || this.disposed) return;
435
472
  this.enabled = true;
@@ -459,16 +496,44 @@ var PortalClient = class {
459
496
  this.ws?.close();
460
497
  this.ws = null;
461
498
  }
462
- /** Grant one agent access to this machine (default-deny; user-initiated). */
463
- async grantAgent(agentId) {
464
- const deviceId = await this.ensureDeviceId();
465
- await grantPortalAccess(this.opts, {
466
- deviceId,
467
- agentId
499
+ /**
500
+ * Grant one agent access to this machine (default-deny; user-initiated).
501
+ * `conversationId` is the conversation whose run asked (null when the grant
502
+ * is not answering an in-chat request) — the server uses it to wake the
503
+ * waiting agent.
504
+ */
505
+ async grantAgent(agentId, conversationId) {
506
+ const auth = this.sessionAuth();
507
+ if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
508
+ await grantPortalAccess(auth, {
509
+ deviceId: await this.ensureDeviceId(),
510
+ agentId,
511
+ conversationId
468
512
  });
469
513
  this.granted.add(agentId);
470
514
  this.emit();
471
515
  }
516
+ /** Session-authed REST auth, or null when only a device token is held. */
517
+ sessionAuth() {
518
+ const { sessionToken } = this.opts.credentials();
519
+ return sessionToken ? {
520
+ appUrl: this.opts.appUrl,
521
+ sessionToken
522
+ } : null;
523
+ }
524
+ /**
525
+ * The bearer token for the portal WebSocket dial: minted from the session
526
+ * when one is held, otherwise the pre-minted device token as-is.
527
+ */
528
+ async wsToken() {
529
+ const creds = this.opts.credentials();
530
+ if (creds.sessionToken) return mintPortalDeviceToken({
531
+ appUrl: this.opts.appUrl,
532
+ sessionToken: creds.sessionToken
533
+ });
534
+ if (creds.deviceToken) return creds.deviceToken;
535
+ throw new Error("no portal credentials (not signed in)");
536
+ }
472
537
  setStatus(status, error = null) {
473
538
  this.status = status;
474
539
  this.error = error;
@@ -493,7 +558,7 @@ var PortalClient = class {
493
558
  while (this.enabled && !this.disposed) {
494
559
  this.setStatus("connecting");
495
560
  try {
496
- const token = await mintPortalDeviceToken(this.opts);
561
+ const token = await this.wsToken();
497
562
  await this.runConnection(token);
498
563
  backoff = INITIAL_BACKOFF_MS;
499
564
  } catch (err) {
@@ -547,8 +612,10 @@ var PortalClient = class {
547
612
  throw new Error("this machine is not connected yet");
548
613
  }
549
614
  async refreshDevice() {
615
+ const auth = this.sessionAuth();
616
+ if (!auth) return;
550
617
  try {
551
- const { devices } = await fetchPortalDevices(this.opts);
618
+ const { devices } = await fetchPortalDevices(auth);
552
619
  const mine = findThisDevice(devices, this.machineName);
553
620
  if (!mine) return;
554
621
  this.deviceId = mine.id;
@@ -565,9 +632,11 @@ var PortalClient = class {
565
632
  async syncDeviceState() {
566
633
  await this.refreshDevice();
567
634
  if (this.legacyGrantsChecked) return;
635
+ const auth = this.sessionAuth();
636
+ if (!auth) return;
568
637
  this.legacyGrantsChecked = true;
569
638
  try {
570
- if (await unifyLegacyCliGrants(this.opts, this.machineName) > 0) await this.refreshDevice();
639
+ if (await unifyLegacyCliGrants(auth, this.machineName) > 0) await this.refreshDevice();
571
640
  } catch (_error) {}
572
641
  }
573
642
  };
@@ -581,4 +650,4 @@ function sleep(ms) {
581
650
  }
582
651
 
583
652
  //#endregion
584
- 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 };
653
+ export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };