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.
- package/README.md +15 -4
- package/dist/js/api-DG5W6iwx.mjs +131 -0
- package/dist/js/bin.mjs +79 -33
- package/dist/js/{boot-B48inW5D.mjs → boot-BuxaHgFi.mjs} +241 -74
- package/dist/js/client-BuU34IVE.mjs +5 -0
- package/dist/js/{client-mykp1DVb.mjs → client-DabRpc_T.mjs} +187 -118
- package/dist/js/{daemon-CfbpCjAw.mjs → daemon-CYYE2BTu.mjs} +179 -28
- package/dist/js/{daemon-D4ZX3wNG.mjs → daemon-Dk5LeS1S.mjs} +3 -2
- package/dist/js/{daemon-client-6xtta_n2.mjs → daemon-client-3A9afTFC.mjs} +35 -10
- package/dist/js/daemon-client-Dh7G9RxC.mjs +7 -0
- package/dist/js/forward-18QoL5dO.mjs +68 -0
- package/dist/js/{print-ba_0hiV9.mjs → print-Bo06tvfV.mjs} +2 -2
- package/dist/js/{print-CbayCa87.mjs → print-ClPkgR9z.mjs} +105 -29
- package/dist/js/{print-share-ZV88PqDu.mjs → print-share-CKLPmsg0.mjs} +5 -2
- package/dist/js/{profiler-94qrV53c.mjs → profiler-Zs3BHURc.mjs} +2 -2
- package/dist/js/{raw-pty-B6mAroiI.mjs → raw-pty-DY4KelZW.mjs} +1 -1
- package/dist/js/raw-pty-DmdUf4_w.mjs +5 -0
- package/dist/js/{rest-DADJh0bi.mjs → rest-D29qNkto.mjs} +1 -1
- package/dist/js/{rest-BY2nADw5.mjs → rest-I3imNduB.mjs} +6 -11
- package/package.json +2 -2
- package/dist/js/client-NBj0KZSP.mjs +0 -4
- package/dist/js/daemon-client-BHehCLrO.mjs +0 -6
- package/dist/js/raw-pty-3EkG-jjH.mjs +0 -5
- /package/dist/js/{client-Cn2af31H.mjs → client-c4c5MmgN.mjs} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
|
52
|
-
* distinct daemons and never share a portal identity
|
|
53
|
-
*
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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))
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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.
|
|
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,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-
|
|
3
|
-
import "./rest-
|
|
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 };
|