skydive-cli 0.5.0-beta.1 → 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
  /**
@@ -134,6 +135,18 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
134
135
  conversationId: z.string().nullable().optional()
135
136
  }),
136
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
+ }),
137
150
  z.object({ t: z.literal("pause") }),
138
151
  z.object({ t: z.literal("resume") }),
139
152
  z.object({ t: z.literal("cancel") }),
@@ -169,6 +182,62 @@ function decodeFrame(frame) {
169
182
  };
170
183
  }
171
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
+
172
241
  //#endregion
173
242
  //#region ../portal-daemon/src/exec.ts
174
243
  /**
@@ -181,6 +250,7 @@ function decodeFrame(frame) {
181
250
  */
182
251
  var JobManager = class {
183
252
  jobs = /* @__PURE__ */ new Map();
253
+ reverse = /* @__PURE__ */ new Map();
184
254
  constructor(opts) {
185
255
  this.opts = opts;
186
256
  }
@@ -200,12 +270,35 @@ var JobManager = class {
200
270
  job.child.kill("SIGKILL");
201
271
  }
202
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();
203
278
  }
204
279
  handleCtrl(id, msg) {
205
280
  switch (msg.t) {
206
281
  case "open":
207
282
  this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
208
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
+ }
209
302
  case "stdin_eof":
210
303
  this.jobs.get(id)?.child.stdin.end();
211
304
  return;
@@ -217,12 +310,51 @@ var JobManager = class {
217
310
  return;
218
311
  case "cancel":
219
312
  this.jobs.get(id)?.child.kill("SIGKILL");
313
+ this.stopReverse(id);
220
314
  return;
221
315
  case "close":
222
- case "error": return;
316
+ case "error":
317
+ case "reverse_listening": return;
223
318
  default: return msg;
224
319
  }
225
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
+ }
226
358
  setPaused(id, paused) {
227
359
  const job = this.jobs.get(id);
228
360
  if (!job) return;
@@ -296,111 +428,6 @@ var JobManager = class {
296
428
  }
297
429
  };
298
430
 
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
431
  //#endregion
405
432
  //#region ../portal-daemon/src/client.ts
406
433
  const INITIAL_BACKOFF_MS = 500;
@@ -469,13 +496,19 @@ var PortalClient = class {
469
496
  this.ws?.close();
470
497
  this.ws = null;
471
498
  }
472
- /** Grant one agent access to this machine (default-deny; user-initiated). */
473
- async grantAgent(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) {
474
506
  const auth = this.sessionAuth();
475
507
  if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
476
508
  await grantPortalAccess(auth, {
477
509
  deviceId: await this.ensureDeviceId(),
478
- agentId
510
+ agentId,
511
+ conversationId
479
512
  });
480
513
  this.granted.add(agentId);
481
514
  this.emit();
@@ -617,4 +650,4 @@ function sleep(ms) {
617
650
  }
618
651
 
619
652
  //#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 };
653
+ export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { s as isRecord, t as PortalClient } from "./client-Dd5sMXPv.mjs";
2
+ import { n as isRecord, t as PortalClient } from "./client-DabRpc_T.mjs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { z } from "zod";
@@ -8,6 +8,43 @@ import { createHash } from "node:crypto";
8
8
  import { connect, createServer } from "node:net";
9
9
  import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
10
10
 
11
+ //#region ../portal-daemon/src/build-stamp.ts
12
+ /**
13
+ * This build's stamp: the unix commit time (seconds) of the source it was
14
+ * compiled from. Empty string when unstamped (a dev source run without the
15
+ * env override).
16
+ *
17
+ * Why a commit time and not a package version: the daemon ships inside two
18
+ * independently-versioned installers (the skydive CLI and the desktop app),
19
+ * so their semvers are not comparable — but both build from this repo, so
20
+ * the commit time of the built tree is one monotonic clock they share.
21
+ */
22
+ function portalDaemonBuild() {
23
+ return "1786598530";
24
+ }
25
+ /**
26
+ * Whether a client carrying `mine` should replace a running daemon carrying
27
+ * `theirs` (newest build wins):
28
+ *
29
+ * - an unstamped client never takes over — it can't prove it's newer;
30
+ * - a stamped client replaces an unstamped daemon — every stamped build
31
+ * postdates stamping, so the unstamped daemon is older by construction;
32
+ * - otherwise strictly greater wins; equal keeps the incumbent, so two
33
+ * identical builds never bounce the daemon between them.
34
+ */
35
+ function isNewerBuild(mine, theirs) {
36
+ const mineAt = parseStamp(mine);
37
+ if (mineAt === null) return false;
38
+ const theirsAt = parseStamp(theirs);
39
+ if (theirsAt === null) return true;
40
+ return mineAt > theirsAt;
41
+ }
42
+ function parseStamp(stamp) {
43
+ if (!/^[0-9]+$/.test(stamp)) return null;
44
+ return Number(stamp);
45
+ }
46
+
47
+ //#endregion
11
48
  //#region ../portal-daemon/src/local-protocol.ts
12
49
  /**
13
50
  * Local IPC between the portal DAEMON and the `skydive` CLI processes attached
@@ -138,10 +175,18 @@ const clientCwdSchema = z.object({
138
175
  t: z.literal("cwd"),
139
176
  cwd: z.string().min(1)
140
177
  });
141
- /** `grant` — authorize one agent to reach this machine (user-initiated). */
178
+ /**
179
+ * `grant` — authorize one agent to reach this machine (user-initiated).
180
+ * `conversationId` names the conversation whose run asked for access (the
181
+ * in-chat approval card); the grant carries it upstream so the server wakes
182
+ * the waiting agent. Defaults to null on parse so hellos from CLI builds that
183
+ * predate the field keep granting against a newer daemon — they just skip the
184
+ * wake, same as before the field existed.
185
+ */
142
186
  const clientGrantSchema = z.object({
143
187
  t: z.literal("grant"),
144
- agentId: z.string().min(1)
188
+ agentId: z.string().min(1),
189
+ conversationId: z.string().nullish().default(null)
145
190
  });
146
191
  /**
147
192
  * `decline` — the user answered no to an agent's request for this machine
@@ -220,6 +265,7 @@ const daemonStatusResultSchema = z.object({
220
265
  v: z.number(),
221
266
  pid: z.number(),
222
267
  appUrl: z.string(),
268
+ build: z.string().default(""),
223
269
  portal: z.object({
224
270
  status: z.enum([
225
271
  "off",
@@ -241,6 +287,12 @@ const daemonMessageSchema = z.discriminatedUnion("t", [
241
287
  daemonHelloOkSchema,
242
288
  daemonStatusResultSchema
243
289
  ]);
290
+ /**
291
+ * Encoding is the pre-parse direction, so it takes the schemas' INPUT types:
292
+ * fields with a `.default()` (e.g. `hello.tokenKind`) are required after a
293
+ * parse but optional on the wire, and a sender omitting one — an older CLI
294
+ * build that predates the field — must stay expressible.
295
+ */
244
296
  function encodeLine(msg) {
245
297
  return `${JSON.stringify(msg)}\n`;
246
298
  }
@@ -411,7 +463,7 @@ var PortalDaemon = class {
411
463
  case "grant":
412
464
  this.declined.delete(msg.agentId);
413
465
  this.ensureClient();
414
- this.client?.grantAgent(msg.agentId).catch((error) => {
466
+ this.client?.grantAgent(msg.agentId, msg.conversationId).catch((error) => {
415
467
  this.logError("grantAgent failed", error);
416
468
  });
417
469
  return;
@@ -510,6 +562,7 @@ var PortalDaemon = class {
510
562
  v: LOCAL_PROTOCOL_VERSION,
511
563
  pid: process.pid,
512
564
  appUrl: this.appUrl,
565
+ build: portalDaemonBuild(),
513
566
  portal: {
514
567
  status: portal?.status ?? "off",
515
568
  machineName: portal?.machineName ?? "",
@@ -567,10 +620,23 @@ var PortalDaemon = class {
567
620
  * before dispatching a normal command — the same pattern the update-check
568
621
  * worker uses, so it survives bundling (import.meta.url points at the bundle,
569
622
  * not a standalone daemon module).
623
+ *
624
+ * Newest build wins: the daemon is embedded in two independently-updated
625
+ * installers (the skydive CLI and the desktop app), and whoever spawned first
626
+ * would otherwise hold the socket forever — a stale desktop daemon serving a
627
+ * newer CLI indefinitely. So when a daemon is already listening, compare its
628
+ * build stamp to ours: if ours is strictly newer, ask it to shut down (it
629
+ * drains attached clients, who reconnect within ~500ms) and spawn from this
630
+ * binary. Both spawners apply the same rule, so a host converges on the
631
+ * newest installed build with at most one bounce.
570
632
  */
571
633
  async function ensureDaemonRunning(appUrl) {
572
634
  const { socketPath } = daemonPaths(appUrl);
573
- if (await isDaemonListening(socketPath)) return;
635
+ if (await isDaemonListening(socketPath)) {
636
+ const status = await queryDaemonStatus(appUrl);
637
+ if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
638
+ if (await stopDaemon(appUrl) === "failed") return;
639
+ }
574
640
  const entry = process.argv[1];
575
641
  const args = entry ? [
576
642
  entry,
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import "./client-Dd5sMXPv.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-C6yadKp2.mjs";
2
+ import "./client-DabRpc_T.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-CYYE2BTu.mjs";
4
+ import "./api-DG5W6iwx.mjs";
4
5
 
5
6
  export { runPortalDaemon };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as LOCAL_PROTOCOL_VERSION, d as encodeLine, f as makeLineParser, n as ensureDaemonRunning, p as parseDaemonMessage, u as daemonPaths } from "./daemon-C6yadKp2.mjs";
2
+ import { c as LOCAL_PROTOCOL_VERSION, d as encodeLine, f as makeLineParser, n as ensureDaemonRunning, p as parseDaemonMessage, u as daemonPaths } from "./daemon-CYYE2BTu.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { connect } from "node:net";
5
5
 
@@ -12,7 +12,7 @@ var PortalDaemonClient = class {
12
12
  lastBind = null;
13
13
  fallbackCwd = null;
14
14
  wantEnabled = false;
15
- pendingGrants = /* @__PURE__ */ new Set();
15
+ pendingGrants = /* @__PURE__ */ new Map();
16
16
  pendingDeclines = /* @__PURE__ */ new Map();
17
17
  grantedAgentIds = /* @__PURE__ */ new Set();
18
18
  constructor(opts) {
@@ -50,9 +50,10 @@ var PortalDaemonClient = class {
50
50
  conversationId: this.lastBind.conversationId,
51
51
  cwd: this.lastBind.cwd
52
52
  });
53
- for (const agentId of this.pendingGrants) this.send({
53
+ for (const [agentId, conversationId] of this.pendingGrants) this.send({
54
54
  t: "grant",
55
- agentId
55
+ agentId,
56
+ conversationId
56
57
  });
57
58
  for (const [agentId, declined] of this.pendingDeclines) this.send({
58
59
  t: "decline",
@@ -157,13 +158,16 @@ var PortalDaemonClient = class {
157
158
  * Authorize one agent to run commands on this machine. Resolves once the
158
159
  * request is sent to the daemon (the daemon performs the grant and pushes the
159
160
  * updated state); kept async so it's a drop-in for the old in-process client's
160
- * awaited `grantAgent`.
161
+ * awaited `grantAgent`. `conversationId` names the conversation whose run
162
+ * asked, so the grant can wake the waiting agent; null when the grant is not
163
+ * answering an in-chat request.
161
164
  */
162
- grantAgent(agentId) {
163
- this.pendingGrants.add(agentId);
165
+ grantAgent(agentId, conversationId) {
166
+ this.pendingGrants.set(agentId, conversationId);
164
167
  this.send({
165
168
  t: "grant",
166
- agentId
169
+ agentId,
170
+ conversationId
167
171
  });
168
172
  return Promise.resolve();
169
173
  }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import "./client-DabRpc_T.mjs";
3
+ import "./daemon-CYYE2BTu.mjs";
4
+ import "./api-DG5W6iwx.mjs";
5
+ import { t as PortalDaemonClient } from "./daemon-client-3A9afTFC.mjs";
6
+
7
+ export { PortalDaemonClient };
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { t as fetchForwardTarget } from "./api-DG5W6iwx.mjs";
3
+ import net from "node:net";
4
+ import { WebSocket, createWebSocketStream } from "ws";
5
+
6
+ //#region src/chat/portal/forward.ts
7
+ const TARGET_REFRESH_SAFETY_MS = 12e4;
8
+ /**
9
+ * The reverse portal's client half: listen on the local machine's loopback and
10
+ * pipe each TCP connection to the agent sandbox's daemon (`/portal/tcp`),
11
+ * which pipes to the sandbox's own loopback. The daemon is reached through the
12
+ * agent-webserver edge Worker (sandboxes have public ingress disabled; the
13
+ * Worker owns boot-resolution and injects the sandbox edge-auth token), so a
14
+ * sandbox recycle just costs the next connection a cold-start wait rather
15
+ * than invalidating the forward.
16
+ */
17
+ async function startForward({ auth, agentId, localPort, targetPort, log }) {
18
+ let target = await fetchForwardTarget(auth, agentId);
19
+ let mintedAt = Date.now();
20
+ async function freshTarget() {
21
+ const ttlMs = target.expiresInSeconds * 1e3;
22
+ if (Date.now() - mintedAt > ttlMs - TARGET_REFRESH_SAFETY_MS) {
23
+ target = await fetchForwardTarget(auth, agentId);
24
+ mintedAt = Date.now();
25
+ }
26
+ return target;
27
+ }
28
+ const server = net.createServer((sock) => {
29
+ sock.pause();
30
+ (async () => {
31
+ let resolved;
32
+ try {
33
+ resolved = await freshTarget();
34
+ } catch (err) {
35
+ log(`forward: token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
36
+ sock.destroy();
37
+ return;
38
+ }
39
+ const ws = new WebSocket(`${resolved.daemonOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${resolved.token}` } });
40
+ ws.on("open", () => {
41
+ const stream = createWebSocketStream(ws);
42
+ stream.on("error", () => sock.destroy());
43
+ sock.on("error", () => stream.destroy());
44
+ sock.pipe(stream).pipe(sock);
45
+ sock.resume();
46
+ });
47
+ ws.on("error", (err) => {
48
+ log(`forward: tunnel connect failed: ${err.message}`);
49
+ sock.destroy();
50
+ });
51
+ })();
52
+ });
53
+ await new Promise((resolve, reject) => {
54
+ server.once("error", reject);
55
+ server.listen(localPort, "127.0.0.1", () => {
56
+ server.removeListener("error", reject);
57
+ resolve();
58
+ });
59
+ });
60
+ const addr = server.address();
61
+ return {
62
+ port: addr && typeof addr === "object" ? addr.port : localPort,
63
+ close: () => new Promise((resolve) => server.close(() => resolve()))
64
+ };
65
+ }
66
+
67
+ //#endregion
68
+ export { startForward };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { a as runPrint, i as resolveAgent, n as messageGet, o as toPrintError, r as readStdin, t as collectRunText } from "./print-CbayCa87.mjs";
3
- import "./rest-BY2nADw5.mjs";
2
+ import { a as runPrint, i as resolveAgent, n as messageGet, o as toPrintError, r as readStdin, t as collectRunText } from "./print-ClPkgR9z.mjs";
3
+ import "./rest-I3imNduB.mjs";
4
4
  import "./billing-blocked-2wju4gC_.mjs";
5
5
 
6
6
  export { messageGet, readStdin, resolveAgent, runPrint };