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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { s as isRecord, t as PortalClient } from "./client-mykp1DVb.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
@@ -47,14 +84,35 @@ const LOCAL_PROTOCOL_VERSION = 1;
47
84
  */
48
85
  const PORTAL_DAEMON_FLAG = "--skydive-internal-portal-daemon";
49
86
  /**
87
+ * One daemon per machine requires one socket key per backend — but the prod
88
+ * backend is reachable under three hosts: the CLI's default appUrl is
89
+ * `https://api.skydive.com` while the desktop app carries
90
+ * `https://skydive.com` (and the web is on www). Hashing those raw strings
91
+ * would give each surface its own daemon, and with the merged machine
92
+ * identity both daemons would register the SAME device — the presence
93
+ * contention the daemon exists to prevent. Collapse the known prod aliases to
94
+ * the CLI's canonical form (proven for both the REST surface and the WS
95
+ * upgrade; the apex 301-redirects, which a WS upgrade cannot follow).
96
+ * Non-prod URLs (previews, localhost) pass through untouched apart from
97
+ * trailing-slash trimming.
98
+ */
99
+ function canonicalPortalAppUrl(appUrl) {
100
+ const trimmed = appUrl.replace(/\/+$/, "");
101
+ const host = trimmed.replace(/^https?:\/\//, "").toLowerCase();
102
+ if (host === "skydive.com" || host === "www.skydive.com" || host === "api.skydive.com") return "https://api.skydive.com";
103
+ return trimmed;
104
+ }
105
+ /**
50
106
  * Per-user, per-app daemon socket + state paths. Scoped by OS user and a hash
51
- * of the appUrl so two logins or two deployments (prod vs a PR preview) get
52
- * distinct daemons and never share a portal identity. Kept under the OS temp
53
- * dir because Unix socket paths are length-limited (~104 bytes on macOS).
107
+ * of the CANONICAL appUrl so two logins or two deployments (prod vs a PR
108
+ * preview) get distinct daemons and never share a portal identity while
109
+ * every alias of one deployment (see {@link canonicalPortalAppUrl}) shares
110
+ * one. Kept under the OS temp dir because Unix socket paths are
111
+ * length-limited (~104 bytes on macOS).
54
112
  */
55
113
  function daemonPaths(appUrl) {
56
114
  const user = safeSegment(os.userInfo().username || "user");
57
- const app = createHash("sha256").update(appUrl).digest("hex").slice(0, 12);
115
+ const app = createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12);
58
116
  const dir = path.join(os.tmpdir(), `skydive-portal-${user}-${app}`);
59
117
  return {
60
118
  dir,
@@ -67,17 +125,30 @@ function safeSegment(value) {
67
125
  return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "user";
68
126
  }
69
127
  /**
70
- * `hello` — a CLI attaches. `sessionId` is unique per CLI process; `token` is
71
- * the CLI's session token, which the daemon needs to mint the portal device
72
- * token (the daemon is spawned lazily and holds no session of its own). The
73
- * daemon uses the FIRST hello's token to connect and refreshes from later
74
- * hellos so a long-lived daemon can outlive the CLI that spawned it.
128
+ * `hello` — a client attaches. `sessionId` is unique per client process.
129
+ * `token` authenticates the daemon's portal connection; `tokenKind` says what
130
+ * it is:
131
+ *
132
+ * - `session` a CLI session token. The daemon mints short-lived portal
133
+ * device tokens from it, and can call the session-authed portal REST
134
+ * surface (device refresh, grants).
135
+ * - `device` — a pre-minted portal device token (the desktop app is handed
136
+ * one by the web bridge and holds no session of its own). Dials the portal
137
+ * WebSocket directly; the session-only REST surface is unavailable until
138
+ * some client supplies a session token.
139
+ *
140
+ * The daemon uses the FIRST hello's token to connect and refreshes from later
141
+ * hellos so a long-lived daemon can outlive the client that spawned it.
142
+ * `tokenKind` defaults to `session` on parse: hellos from CLI builds that
143
+ * predate the field are session-token clients by definition, and the default
144
+ * keeps them attaching to a newer daemon.
75
145
  */
76
146
  const clientHelloSchema = z.object({
77
147
  t: z.literal("hello"),
78
148
  v: z.number(),
79
149
  sessionId: z.string().min(1),
80
- token: z.string().min(1)
150
+ token: z.string().min(1),
151
+ tokenKind: z.enum(["session", "device"]).default("session")
81
152
  });
82
153
  /**
83
154
  * `bind` — associate a conversation with a working directory. Sent on chat
@@ -104,10 +175,32 @@ const clientCwdSchema = z.object({
104
175
  t: z.literal("cwd"),
105
176
  cwd: z.string().min(1)
106
177
  });
107
- /** `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
+ */
108
186
  const clientGrantSchema = z.object({
109
187
  t: z.literal("grant"),
110
- agentId: z.string().min(1)
188
+ agentId: z.string().min(1),
189
+ conversationId: z.string().nullish().default(null)
190
+ });
191
+ /**
192
+ * `decline` — the user answered no to an agent's request for this machine
193
+ * (`declined: true`), or re-opened the door for it (`declined: false`).
194
+ *
195
+ * The daemon holds this because a decline is about the MACHINE, not one
196
+ * terminal: every attached CLI raises its own grant prompt off the daemon's
197
+ * shared state, so a per-process no would be re-asked by every other open
198
+ * conversation and by the next `skydive` launch.
199
+ */
200
+ const clientDeclineSchema = z.object({
201
+ t: z.literal("decline"),
202
+ agentId: z.string().min(1),
203
+ declined: z.boolean()
111
204
  });
112
205
  /** `bye` — a CLI detaches cleanly (its conversations drop from the map). */
113
206
  const clientByeSchema = z.object({
@@ -133,6 +226,7 @@ const clientMessageSchema = z.discriminatedUnion("t", [
133
226
  clientDisableSchema,
134
227
  clientCwdSchema,
135
228
  clientGrantSchema,
229
+ clientDeclineSchema,
136
230
  clientByeSchema,
137
231
  clientStatusSchema,
138
232
  clientShutdownSchema
@@ -152,7 +246,8 @@ const daemonStateSchema = z.object({
152
246
  machineName: z.string(),
153
247
  friendlyName: z.string(),
154
248
  error: z.string().nullable(),
155
- grantedAgentIds: z.array(z.string())
249
+ grantedAgentIds: z.array(z.string()),
250
+ declinedAgentIds: z.array(z.string()).default([])
156
251
  });
157
252
  /** `hello_ok` — the daemon accepts an attach and reports its protocol version. */
158
253
  const daemonHelloOkSchema = z.object({
@@ -170,6 +265,7 @@ const daemonStatusResultSchema = z.object({
170
265
  v: z.number(),
171
266
  pid: z.number(),
172
267
  appUrl: z.string(),
268
+ build: z.string().default(""),
173
269
  portal: z.object({
174
270
  status: z.enum([
175
271
  "off",
@@ -180,7 +276,8 @@ const daemonStatusResultSchema = z.object({
180
276
  machineName: z.string(),
181
277
  friendlyName: z.string(),
182
278
  error: z.string().nullable(),
183
- grantedAgentIds: z.array(z.string())
279
+ grantedAgentIds: z.array(z.string()),
280
+ declinedAgentIds: z.array(z.string()).default([])
184
281
  }),
185
282
  clientCount: z.number(),
186
283
  cwds: z.record(z.string(), z.string())
@@ -190,6 +287,12 @@ const daemonMessageSchema = z.discriminatedUnion("t", [
190
287
  daemonHelloOkSchema,
191
288
  daemonStatusResultSchema
192
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
+ */
193
296
  function encodeLine(msg) {
194
297
  return `${JSON.stringify(msg)}\n`;
195
298
  }
@@ -254,18 +357,21 @@ function parseDaemonMessage(line) {
254
357
  /** Grace period after the last client detaches before the daemon exits. */
255
358
  const IDLE_SHUTDOWN_MS = 3e4;
256
359
  var PortalDaemon = class {
360
+ appUrl;
257
361
  paths;
258
362
  server = null;
259
363
  client = null;
260
364
  lastState = null;
261
365
  conns = /* @__PURE__ */ new Set();
262
366
  cwds = /* @__PURE__ */ new Map();
367
+ declined = /* @__PURE__ */ new Set();
263
368
  fallbackCwd;
264
369
  idleTimer = null;
265
370
  sessionToken = null;
371
+ deviceToken = null;
266
372
  constructor(appUrl) {
267
- this.appUrl = appUrl;
268
- this.paths = daemonPaths(appUrl);
373
+ this.appUrl = canonicalPortalAppUrl(appUrl);
374
+ this.paths = daemonPaths(this.appUrl);
269
375
  this.fallbackCwd = process.env.HOME ?? process.cwd();
270
376
  }
271
377
  /** Start listening. Rejects if the socket is already held by another daemon. */
@@ -319,13 +425,14 @@ var PortalDaemon = class {
319
425
  switch (msg.t) {
320
426
  case "hello":
321
427
  conn.sessionId = msg.sessionId;
322
- this.sessionToken = msg.token;
428
+ if (msg.tokenKind === "device") this.deviceToken = msg.token;
429
+ else this.sessionToken = msg.token;
323
430
  this.ensureClient();
324
431
  this.send(conn, {
325
432
  t: "hello_ok",
326
433
  v: LOCAL_PROTOCOL_VERSION
327
434
  });
328
- if (this.lastState) this.send(conn, stateMessage(this.lastState));
435
+ if (this.lastState) this.send(conn, stateMessage(this.lastState, this.declined));
329
436
  return;
330
437
  case "bind":
331
438
  conn.conversations.add(msg.conversationId);
@@ -342,12 +449,21 @@ var PortalDaemon = class {
342
449
  for (const other of this.conns) other.wantsShare = false;
343
450
  this.client?.disable();
344
451
  return;
452
+ case "decline":
453
+ if (msg.declined) {
454
+ this.declined.add(msg.agentId);
455
+ if ((this.lastState?.grantedAgentIds.length ?? 0) === 0) this.client?.disable();
456
+ } else this.declined.delete(msg.agentId);
457
+ this.persistState();
458
+ this.broadcastState();
459
+ return;
345
460
  case "cwd":
346
461
  this.fallbackCwd = msg.cwd;
347
462
  return;
348
463
  case "grant":
464
+ this.declined.delete(msg.agentId);
349
465
  this.ensureClient();
350
- this.client?.grantAgent(msg.agentId).catch((error) => {
466
+ this.client?.grantAgent(msg.agentId, msg.conversationId).catch((error) => {
351
467
  this.logError("grantAgent failed", error);
352
468
  });
353
469
  return;
@@ -375,14 +491,17 @@ var PortalDaemon = class {
375
491
  }
376
492
  /** Create the single PortalClient the first time a client needs the portal. */
377
493
  ensureClient() {
378
- if (this.client || !this.sessionToken) return;
494
+ if (this.client || !this.sessionToken && !this.deviceToken) return;
379
495
  this.client = new PortalClient({
380
496
  appUrl: this.appUrl,
381
- sessionToken: this.sessionToken,
497
+ credentials: () => ({
498
+ sessionToken: this.sessionToken,
499
+ deviceToken: this.deviceToken
500
+ }),
382
501
  resolveCwd: (conversationId) => this.resolveCwd(conversationId),
383
502
  onState: (state) => {
384
503
  this.lastState = state;
385
- this.broadcast(stateMessage(state));
504
+ this.broadcastState();
386
505
  }
387
506
  });
388
507
  }
@@ -400,6 +519,11 @@ var PortalDaemon = class {
400
519
  broadcast(msg) {
401
520
  for (const conn of this.conns) this.send(conn, msg);
402
521
  }
522
+ /** Push the current portal state (plus the machine's declines) to every CLI. */
523
+ broadcastState() {
524
+ if (!this.lastState) return;
525
+ this.broadcast(stateMessage(this.lastState, this.declined));
526
+ }
403
527
  armIdleTimer() {
404
528
  this.clearIdleTimer();
405
529
  this.idleTimer = setTimeout(() => this.shutdown(), IDLE_SHUTDOWN_MS);
@@ -417,6 +541,13 @@ var PortalDaemon = class {
417
541
  const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${context}: ${message}\n`;
418
542
  appendFile(this.paths.logPath, line).catch((_error) => {});
419
543
  }
544
+ /** Close the listener without exiting the process (tests own the process). */
545
+ stopForTest() {
546
+ this.clearIdleTimer();
547
+ this.client?.dispose();
548
+ this.server?.close();
549
+ this.server = null;
550
+ }
420
551
  shutdown() {
421
552
  if (this.conns.size > 0) return;
422
553
  this.client?.dispose();
@@ -431,12 +562,14 @@ var PortalDaemon = class {
431
562
  v: LOCAL_PROTOCOL_VERSION,
432
563
  pid: process.pid,
433
564
  appUrl: this.appUrl,
565
+ build: portalDaemonBuild(),
434
566
  portal: {
435
567
  status: portal?.status ?? "off",
436
568
  machineName: portal?.machineName ?? "",
437
569
  friendlyName: portal?.friendlyName ?? "",
438
570
  error: portal?.error ?? null,
439
- grantedAgentIds: portal?.grantedAgentIds ?? []
571
+ grantedAgentIds: portal?.grantedAgentIds ?? [],
572
+ declinedAgentIds: [...this.declined]
440
573
  },
441
574
  clientCount: [...this.conns].filter((c) => c.sessionId !== null).length,
442
575
  cwds: Object.fromEntries(this.cwds)
@@ -462,13 +595,17 @@ var PortalDaemon = class {
462
595
  const parsed = JSON.parse(raw);
463
596
  if (isRecord(parsed) && parsed.version === LOCAL_PROTOCOL_VERSION && isRecord(parsed.cwds)) {
464
597
  for (const [id, cwd] of Object.entries(parsed.cwds)) if (typeof cwd === "string") this.cwds.set(id, cwd);
598
+ if (Array.isArray(parsed.declined)) {
599
+ for (const id of parsed.declined) if (typeof id === "string") this.declined.add(id);
600
+ }
465
601
  }
466
602
  } catch (_error) {}
467
603
  }
468
604
  persistState() {
469
605
  const state = {
470
606
  version: LOCAL_PROTOCOL_VERSION,
471
- cwds: Object.fromEntries(this.cwds)
607
+ cwds: Object.fromEntries(this.cwds),
608
+ declined: [...this.declined]
472
609
  };
473
610
  writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
474
611
  this.logError("persistState failed", error);
@@ -483,10 +620,23 @@ var PortalDaemon = class {
483
620
  * before dispatching a normal command — the same pattern the update-check
484
621
  * worker uses, so it survives bundling (import.meta.url points at the bundle,
485
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.
486
632
  */
487
633
  async function ensureDaemonRunning(appUrl) {
488
634
  const { socketPath } = daemonPaths(appUrl);
489
- 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
+ }
490
640
  const entry = process.argv[1];
491
641
  const args = entry ? [
492
642
  entry,
@@ -615,14 +765,15 @@ async function stopDaemon(appUrl) {
615
765
  }
616
766
  return await isDaemonListening(socketPath) ? "failed" : "stopped";
617
767
  }
618
- function stateMessage(state) {
768
+ function stateMessage(state, declined) {
619
769
  return {
620
770
  t: "state",
621
771
  status: state.status,
622
772
  machineName: state.machineName,
623
773
  friendlyName: state.friendlyName,
624
774
  error: state.error,
625
- grantedAgentIds: state.grantedAgentIds
775
+ grantedAgentIds: state.grantedAgentIds,
776
+ declinedAgentIds: [...declined]
626
777
  };
627
778
  }
628
779
  function sleep(ms) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import "./client-mykp1DVb.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-CfbpCjAw.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-CfbpCjAw.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,8 @@ 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
+ pendingDeclines = /* @__PURE__ */ new Map();
16
17
  grantedAgentIds = /* @__PURE__ */ new Set();
17
18
  constructor(opts) {
18
19
  this.opts = opts;
@@ -35,7 +36,8 @@ var PortalDaemonClient = class {
35
36
  t: "hello",
36
37
  v: LOCAL_PROTOCOL_VERSION,
37
38
  sessionId: this.sessionId,
38
- token: this.opts.sessionToken
39
+ token: this.opts.sessionToken,
40
+ tokenKind: "session"
39
41
  });
40
42
  if (this.wantEnabled) this.send({ t: "enable" });
41
43
  if (this.fallbackCwd) this.send({
@@ -48,9 +50,15 @@ var PortalDaemonClient = class {
48
50
  conversationId: this.lastBind.conversationId,
49
51
  cwd: this.lastBind.cwd
50
52
  });
51
- for (const agentId of this.pendingGrants) this.send({
53
+ for (const [agentId, conversationId] of this.pendingGrants) this.send({
52
54
  t: "grant",
53
- agentId
55
+ agentId,
56
+ conversationId
57
+ });
58
+ for (const [agentId, declined] of this.pendingDeclines) this.send({
59
+ t: "decline",
60
+ agentId,
61
+ declined
54
62
  });
55
63
  });
56
64
  socket.on("data", (chunk) => {
@@ -82,7 +90,8 @@ var PortalDaemonClient = class {
82
90
  machineName: msg.machineName,
83
91
  friendlyName: msg.friendlyName,
84
92
  error: msg.error,
85
- grantedAgentIds: msg.grantedAgentIds
93
+ grantedAgentIds: msg.grantedAgentIds,
94
+ declinedAgentIds: msg.declinedAgentIds
86
95
  });
87
96
  }
88
97
  }
@@ -108,6 +117,19 @@ var PortalDaemonClient = class {
108
117
  this.send({ t: "disable" });
109
118
  }
110
119
  /**
120
+ * Record that the user said no (or yes again) to one agent on this machine.
121
+ * The daemon owns the answer, so every attached CLI stops asking — and it
122
+ * closes the share when nothing else is granted.
123
+ */
124
+ decline(agentId, declined) {
125
+ this.pendingDeclines.set(agentId, declined);
126
+ this.send({
127
+ t: "decline",
128
+ agentId,
129
+ declined
130
+ });
131
+ }
132
+ /**
111
133
  * Set the directory an exec runs in when the daemon can't place its
112
134
  * conversation. `skydive portal open` shares one directory and has no
113
135
  * conversation of its own, so this is how it routes.
@@ -136,13 +158,16 @@ var PortalDaemonClient = class {
136
158
  * Authorize one agent to run commands on this machine. Resolves once the
137
159
  * request is sent to the daemon (the daemon performs the grant and pushes the
138
160
  * updated state); kept async so it's a drop-in for the old in-process client's
139
- * 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.
140
164
  */
141
- grantAgent(agentId) {
142
- this.pendingGrants.add(agentId);
165
+ grantAgent(agentId, conversationId) {
166
+ this.pendingGrants.set(agentId, conversationId);
143
167
  this.send({
144
168
  t: "grant",
145
- agentId
169
+ agentId,
170
+ conversationId
146
171
  });
147
172
  return Promise.resolve();
148
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 };