skydive-cli 0.5.0-beta.2 → 0.5.0-beta.21
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/CHANGELOG.md +2 -0
- package/README.md +15 -4
- package/dist/js/api-DG5W6iwx.mjs +131 -0
- package/dist/js/bin.mjs +105 -52
- package/dist/js/{boot-DFYFjlht.mjs → boot-DcQhJadu.mjs} +1181 -192
- package/dist/js/{client-Dd5sMXPv.mjs → client-DbqRBquD.mjs} +206 -133
- package/dist/js/client-hH2PJL8y.mjs +5 -0
- package/dist/js/{daemon-DDBIumf1.mjs → daemon-C6tZH1dO.mjs} +3 -2
- package/dist/js/{daemon-Bq93vOIk.mjs → daemon-Dj9tGT12.mjs} +93 -7
- package/dist/js/{daemon-client-Bewt98dE.mjs → daemon-client-BNK77jX_.mjs} +12 -8
- package/dist/js/daemon-client-CgpMgM8Q.mjs +7 -0
- package/dist/js/forward-18QoL5dO.mjs +68 -0
- package/dist/js/{print-ba_0hiV9.mjs → print-C8DHKrJu.mjs} +2 -2
- package/dist/js/{print-CbayCa87.mjs → print-OvYyj6Uk.mjs} +106 -30
- package/dist/js/{print-share-C1LPsgTP.mjs → print-share-BH9gvCls.mjs} +3 -1
- package/dist/js/{profiler-y43HVmq7.mjs → profiler-Bl25cyUp.mjs} +39 -4
- 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-DW05dMMc.mjs} +1 -1
- package/dist/js/{rest-BY2nADw5.mjs → rest-Dx6b-Nq_.mjs} +25 -12
- package/package.json +1 -1
- package/dist/js/client-CKBQ8pft.mjs +0 -4
- package/dist/js/daemon-client-srAit8Fz.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,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
-
import
|
|
6
|
-
import {
|
|
5
|
+
import * as childProcess from "node:child_process";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { WebSocket, createWebSocketStream } from "ws";
|
|
7
9
|
|
|
8
10
|
//#region ../portal-daemon/src/machine.ts
|
|
11
|
+
const SCUTIL_TIMEOUT_MS = 2e3;
|
|
12
|
+
const SCUTIL_ATTEMPTS = 3;
|
|
9
13
|
/**
|
|
10
14
|
* Identity this machine registers under on the portal.
|
|
11
15
|
*
|
|
@@ -14,38 +18,74 @@ import { WebSocket } from "ws";
|
|
|
14
18
|
* row, so a grant applies to the machine no matter which surface the user
|
|
15
19
|
* shared from. The name must therefore match what the desktop app registers —
|
|
16
20
|
* on macOS that is `scutil --get LocalHostName` (the Rust client's own
|
|
17
|
-
* derivation, stabler than the network-assigned hostname)
|
|
18
|
-
*
|
|
21
|
+
* derivation, stabler than the network-assigned hostname).
|
|
22
|
+
*
|
|
23
|
+
* The machine name is a durable IDENTITY, not a fresh network reading: every
|
|
24
|
+
* grant hangs off the device row keyed by this name, so if it changes the
|
|
25
|
+
* device flips to a brand-new row with ZERO grants and every agent re-prompts
|
|
26
|
+
* across every open CLI. `os.hostname()` is NOT durable — macOS rewrites it
|
|
27
|
+
* from DHCP/DNS per network — so it must never be allowed to silently replace
|
|
28
|
+
* a name we already registered under. Resolution order:
|
|
29
|
+
*
|
|
30
|
+
* 1. `scutil --get LocalHostName` (retried) — the stable, correct source.
|
|
31
|
+
* 2. `previous` — the name we last successfully registered under, persisted
|
|
32
|
+
* by the daemon. Preferred over the volatile hostname so a transient
|
|
33
|
+
* `scutil` failure keeps our existing identity (and grants) intact.
|
|
34
|
+
* 3. `os.hostname()` — last resort for a machine that has NEVER resolved a
|
|
35
|
+
* name (first run, non-macOS, scutil permanently unavailable).
|
|
19
36
|
*
|
|
20
37
|
* Historically the CLI suffixed `-cli` to keep a device distinct from the
|
|
21
38
|
* desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
|
|
22
39
|
*/
|
|
23
|
-
async function resolveMachineIdentity() {
|
|
24
|
-
const
|
|
25
|
-
if (process.platform !== "darwin")
|
|
26
|
-
machineName
|
|
27
|
-
|
|
40
|
+
async function resolveMachineIdentity(previous) {
|
|
41
|
+
const persisted = previous?.trim() || null;
|
|
42
|
+
if (process.platform !== "darwin") {
|
|
43
|
+
const machineName = persisted ?? fallbackHost();
|
|
44
|
+
return {
|
|
45
|
+
machineName,
|
|
46
|
+
friendlyName: machineName,
|
|
47
|
+
machineNameSource: persisted ? "persisted" : "hostname-fallback"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const local = await scutilRead("LocalHostName");
|
|
51
|
+
if (local) return {
|
|
52
|
+
machineName: local,
|
|
53
|
+
friendlyName: await scutilRead("ComputerName") ?? local,
|
|
54
|
+
machineNameSource: "scutil"
|
|
55
|
+
};
|
|
56
|
+
if (persisted) return {
|
|
57
|
+
machineName: persisted,
|
|
58
|
+
friendlyName: persisted,
|
|
59
|
+
machineNameSource: "persisted"
|
|
28
60
|
};
|
|
29
|
-
const
|
|
61
|
+
const fallback = fallbackHost();
|
|
30
62
|
return {
|
|
31
|
-
machineName:
|
|
32
|
-
friendlyName:
|
|
63
|
+
machineName: fallback,
|
|
64
|
+
friendlyName: fallback,
|
|
65
|
+
machineNameSource: "hostname-fallback"
|
|
33
66
|
};
|
|
34
67
|
}
|
|
35
68
|
function fallbackHost() {
|
|
36
69
|
return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
37
70
|
}
|
|
38
71
|
function scutilRead(key) {
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
72
|
+
return attempt(0);
|
|
73
|
+
function attempt(n) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
childProcess.execFile("/usr/sbin/scutil", ["--get", key], { timeout: SCUTIL_TIMEOUT_MS }, (error, stdout) => {
|
|
76
|
+
if (error) {
|
|
77
|
+
if (n + 1 < SCUTIL_ATTEMPTS) {
|
|
78
|
+
resolve(attempt(n + 1));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
resolve(null);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const value = stdout.trim();
|
|
85
|
+
resolve(value.length > 0 ? value : null);
|
|
86
|
+
});
|
|
47
87
|
});
|
|
48
|
-
}
|
|
88
|
+
}
|
|
49
89
|
}
|
|
50
90
|
const INHERITED_ENV = [
|
|
51
91
|
"HOME",
|
|
@@ -134,6 +174,18 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
|
134
174
|
conversationId: z.string().nullable().optional()
|
|
135
175
|
}),
|
|
136
176
|
z.object({ t: z.literal("stdin_eof") }),
|
|
177
|
+
z.object({
|
|
178
|
+
t: z.literal("reverse_listen"),
|
|
179
|
+
listenPort: z.number().int().positive(),
|
|
180
|
+
targetPort: z.number().int().positive(),
|
|
181
|
+
dialOrigin: z.string().min(1),
|
|
182
|
+
token: z.string().min(1)
|
|
183
|
+
}),
|
|
184
|
+
z.object({ t: z.literal("reverse_listening") }),
|
|
185
|
+
z.object({
|
|
186
|
+
t: z.literal("reverse_token"),
|
|
187
|
+
token: z.string().min(1)
|
|
188
|
+
}),
|
|
137
189
|
z.object({ t: z.literal("pause") }),
|
|
138
190
|
z.object({ t: z.literal("resume") }),
|
|
139
191
|
z.object({ t: z.literal("cancel") }),
|
|
@@ -169,6 +221,62 @@ function decodeFrame(frame) {
|
|
|
169
221
|
};
|
|
170
222
|
}
|
|
171
223
|
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region ../portal-daemon/src/reverse-listener.ts
|
|
226
|
+
/**
|
|
227
|
+
* The desktop half of an agent-initiated expose tunnel (`platform portal
|
|
228
|
+
* expose`): listen on the local loopback and pipe each accepted TCP
|
|
229
|
+
* connection to the agent sandbox's daemon (`/portal/tcp`) through the
|
|
230
|
+
* agent-webserver edge Worker — the same per-connection dial-and-pipe as the
|
|
231
|
+
* user-initiated `skydive portal forward`, just started from a directive
|
|
232
|
+
* instead of a terminal. Tunnel bytes never touch the directive stream that
|
|
233
|
+
* created this listener; it only anchors the lifetime and carries the token.
|
|
234
|
+
*
|
|
235
|
+
* `target()` is read per connection so a token refresh (or any future
|
|
236
|
+
* retarget) applies to the next dial without touching established pipes.
|
|
237
|
+
*/
|
|
238
|
+
var ReverseListener = class {
|
|
239
|
+
server = null;
|
|
240
|
+
conns = /* @__PURE__ */ new Set();
|
|
241
|
+
constructor(opts) {
|
|
242
|
+
this.opts = opts;
|
|
243
|
+
}
|
|
244
|
+
start() {
|
|
245
|
+
const server = net.createServer((sock) => {
|
|
246
|
+
this.conns.add(sock);
|
|
247
|
+
sock.once("close", () => this.conns.delete(sock));
|
|
248
|
+
sock.pause();
|
|
249
|
+
const { dialOrigin, targetPort, token } = this.opts.target();
|
|
250
|
+
const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
|
|
251
|
+
ws.on("open", () => {
|
|
252
|
+
const stream = createWebSocketStream(ws);
|
|
253
|
+
stream.on("error", () => sock.destroy());
|
|
254
|
+
sock.on("error", () => stream.destroy());
|
|
255
|
+
sock.pipe(stream).pipe(sock);
|
|
256
|
+
sock.resume();
|
|
257
|
+
});
|
|
258
|
+
ws.on("error", (err) => {
|
|
259
|
+
this.opts.log(`expose: tunnel connect failed: ${err.message}`);
|
|
260
|
+
sock.destroy();
|
|
261
|
+
});
|
|
262
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
263
|
+
this.opts.log(`expose: tunnel rejected (HTTP ${res.statusCode ?? "?"})`);
|
|
264
|
+
res.destroy();
|
|
265
|
+
sock.destroy();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
server.once("error", (err) => this.opts.onListening(err));
|
|
269
|
+
server.listen(this.opts.listenPort, "127.0.0.1", () => this.opts.onListening(null));
|
|
270
|
+
this.server = server;
|
|
271
|
+
}
|
|
272
|
+
stop() {
|
|
273
|
+
this.server?.close();
|
|
274
|
+
this.server = null;
|
|
275
|
+
for (const sock of this.conns) sock.destroy();
|
|
276
|
+
this.conns.clear();
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
172
280
|
//#endregion
|
|
173
281
|
//#region ../portal-daemon/src/exec.ts
|
|
174
282
|
/**
|
|
@@ -181,6 +289,7 @@ function decodeFrame(frame) {
|
|
|
181
289
|
*/
|
|
182
290
|
var JobManager = class {
|
|
183
291
|
jobs = /* @__PURE__ */ new Map();
|
|
292
|
+
reverse = /* @__PURE__ */ new Map();
|
|
184
293
|
constructor(opts) {
|
|
185
294
|
this.opts = opts;
|
|
186
295
|
}
|
|
@@ -200,12 +309,35 @@ var JobManager = class {
|
|
|
200
309
|
job.child.kill("SIGKILL");
|
|
201
310
|
}
|
|
202
311
|
this.jobs.clear();
|
|
312
|
+
for (const rev of this.reverse.values()) {
|
|
313
|
+
rev.settled = true;
|
|
314
|
+
rev.listener.stop();
|
|
315
|
+
}
|
|
316
|
+
this.reverse.clear();
|
|
203
317
|
}
|
|
204
318
|
handleCtrl(id, msg) {
|
|
205
319
|
switch (msg.t) {
|
|
206
320
|
case "open":
|
|
207
321
|
this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
|
|
208
322
|
return;
|
|
323
|
+
case "reverse_listen":
|
|
324
|
+
this.startReverse(id, {
|
|
325
|
+
listenPort: msg.listenPort,
|
|
326
|
+
target: {
|
|
327
|
+
dialOrigin: msg.dialOrigin,
|
|
328
|
+
targetPort: msg.targetPort,
|
|
329
|
+
token: msg.token
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
case "reverse_token": {
|
|
334
|
+
const rev = this.reverse.get(id);
|
|
335
|
+
if (rev) rev.target = {
|
|
336
|
+
...rev.target,
|
|
337
|
+
token: msg.token
|
|
338
|
+
};
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
209
341
|
case "stdin_eof":
|
|
210
342
|
this.jobs.get(id)?.child.stdin.end();
|
|
211
343
|
return;
|
|
@@ -217,12 +349,51 @@ var JobManager = class {
|
|
|
217
349
|
return;
|
|
218
350
|
case "cancel":
|
|
219
351
|
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
352
|
+
this.stopReverse(id);
|
|
220
353
|
return;
|
|
221
354
|
case "close":
|
|
222
|
-
case "error":
|
|
355
|
+
case "error":
|
|
356
|
+
case "reverse_listening": return;
|
|
223
357
|
default: return msg;
|
|
224
358
|
}
|
|
225
359
|
}
|
|
360
|
+
startReverse(id, { listenPort, target }) {
|
|
361
|
+
const rev = {
|
|
362
|
+
settled: false,
|
|
363
|
+
target,
|
|
364
|
+
seq: 0,
|
|
365
|
+
listener: new ReverseListener({
|
|
366
|
+
listenPort,
|
|
367
|
+
target: () => rev.target,
|
|
368
|
+
log: (msg) => {
|
|
369
|
+
if (rev.settled) return;
|
|
370
|
+
this.opts.send(encodeData(id, STREAM.stderr, rev.seq, Buffer.from(msg)));
|
|
371
|
+
rev.seq = rev.seq + 1 >>> 0;
|
|
372
|
+
},
|
|
373
|
+
onListening: (err) => {
|
|
374
|
+
if (err) {
|
|
375
|
+
rev.settled = true;
|
|
376
|
+
this.reverse.delete(id);
|
|
377
|
+
this.opts.send(encodeCtrl(id, {
|
|
378
|
+
t: "error",
|
|
379
|
+
message: `reverse listen failed: ${err.message}`
|
|
380
|
+
}));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.opts.send(encodeCtrl(id, { t: "reverse_listening" }));
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
};
|
|
387
|
+
this.reverse.set(id, rev);
|
|
388
|
+
rev.listener.start();
|
|
389
|
+
}
|
|
390
|
+
stopReverse(id) {
|
|
391
|
+
const rev = this.reverse.get(id);
|
|
392
|
+
if (!rev) return;
|
|
393
|
+
rev.settled = true;
|
|
394
|
+
this.reverse.delete(id);
|
|
395
|
+
rev.listener.stop();
|
|
396
|
+
}
|
|
226
397
|
setPaused(id, paused) {
|
|
227
398
|
const job = this.jobs.get(id);
|
|
228
399
|
if (!job) return;
|
|
@@ -296,111 +467,6 @@ var JobManager = class {
|
|
|
296
467
|
}
|
|
297
468
|
};
|
|
298
469
|
|
|
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
470
|
//#endregion
|
|
405
471
|
//#region ../portal-daemon/src/client.ts
|
|
406
472
|
const INITIAL_BACKOFF_MS = 500;
|
|
@@ -469,13 +535,19 @@ var PortalClient = class {
|
|
|
469
535
|
this.ws?.close();
|
|
470
536
|
this.ws = null;
|
|
471
537
|
}
|
|
472
|
-
/**
|
|
473
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Grant one agent access to this machine (default-deny; user-initiated).
|
|
540
|
+
* `conversationId` is the conversation whose run asked (null when the grant
|
|
541
|
+
* is not answering an in-chat request) — the server uses it to wake the
|
|
542
|
+
* waiting agent.
|
|
543
|
+
*/
|
|
544
|
+
async grantAgent(agentId, conversationId) {
|
|
474
545
|
const auth = this.sessionAuth();
|
|
475
546
|
if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
|
|
476
547
|
await grantPortalAccess(auth, {
|
|
477
548
|
deviceId: await this.ensureDeviceId(),
|
|
478
|
-
agentId
|
|
549
|
+
agentId,
|
|
550
|
+
conversationId
|
|
479
551
|
});
|
|
480
552
|
this.granted.add(agentId);
|
|
481
553
|
this.emit();
|
|
@@ -517,9 +589,10 @@ var PortalClient = class {
|
|
|
517
589
|
}
|
|
518
590
|
async connectLoop() {
|
|
519
591
|
if (!this.machineName) {
|
|
520
|
-
const identity = await resolveMachineIdentity();
|
|
592
|
+
const identity = await resolveMachineIdentity(this.opts.persistedMachineName);
|
|
521
593
|
this.machineName = identity.machineName;
|
|
522
594
|
this.friendlyName = identity.friendlyName;
|
|
595
|
+
this.opts.onMachineName(identity.machineName, identity.machineNameSource);
|
|
523
596
|
}
|
|
524
597
|
let backoff = INITIAL_BACKOFF_MS;
|
|
525
598
|
while (this.enabled && !this.disposed) {
|
|
@@ -617,4 +690,4 @@ function sleep(ms) {
|
|
|
617
690
|
}
|
|
618
691
|
|
|
619
692
|
//#endregion
|
|
620
|
-
export {
|
|
693
|
+
export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
|
|
@@ -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-DbqRBquD.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-Dj9tGT12.mjs";
|
|
4
|
+
import "./api-DG5W6iwx.mjs";
|
|
4
5
|
|
|
5
6
|
export { runPortalDaemon };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { n as isRecord, t as PortalClient } from "./client-DbqRBquD.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 "1786659341";
|
|
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
|
-
/**
|
|
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",
|
|
@@ -319,6 +365,7 @@ var PortalDaemon = class {
|
|
|
319
365
|
conns = /* @__PURE__ */ new Set();
|
|
320
366
|
cwds = /* @__PURE__ */ new Map();
|
|
321
367
|
declined = /* @__PURE__ */ new Set();
|
|
368
|
+
persistedMachineName = null;
|
|
322
369
|
fallbackCwd;
|
|
323
370
|
idleTimer = null;
|
|
324
371
|
sessionToken = null;
|
|
@@ -417,7 +464,7 @@ var PortalDaemon = class {
|
|
|
417
464
|
case "grant":
|
|
418
465
|
this.declined.delete(msg.agentId);
|
|
419
466
|
this.ensureClient();
|
|
420
|
-
this.client?.grantAgent(msg.agentId).catch((error) => {
|
|
467
|
+
this.client?.grantAgent(msg.agentId, msg.conversationId).catch((error) => {
|
|
421
468
|
this.logError("grantAgent failed", error);
|
|
422
469
|
});
|
|
423
470
|
return;
|
|
@@ -453,12 +500,28 @@ var PortalDaemon = class {
|
|
|
453
500
|
deviceToken: this.deviceToken
|
|
454
501
|
}),
|
|
455
502
|
resolveCwd: (conversationId) => this.resolveCwd(conversationId),
|
|
503
|
+
persistedMachineName: this.persistedMachineName,
|
|
504
|
+
onMachineName: (name, source) => this.onMachineName(name, source),
|
|
456
505
|
onState: (state) => {
|
|
457
506
|
this.lastState = state;
|
|
458
507
|
this.broadcastState();
|
|
459
508
|
}
|
|
460
509
|
});
|
|
461
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* The identity resolved on connect. Persist the name so a later scutil
|
|
513
|
+
* failure reuses it instead of adopting the volatile hostname, and log the
|
|
514
|
+
* source — a `hostname-fallback` after we'd previously registered under
|
|
515
|
+
* scutil is the tell that a flap just orphaned this machine's grants.
|
|
516
|
+
*/
|
|
517
|
+
onMachineName(name, source) {
|
|
518
|
+
if (source === "hostname-fallback" && this.persistedMachineName) this.logInfo(`portal identity WARNING: fell back to hostname "${name}" but had previously registered as "${this.persistedMachineName}" — grants may be orphaned`);
|
|
519
|
+
else this.logInfo(`portal identity: "${name}" (source=${source})`);
|
|
520
|
+
if (name && name !== this.persistedMachineName) {
|
|
521
|
+
this.persistedMachineName = name;
|
|
522
|
+
this.persistState();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
462
525
|
/** The cwd an exec for `conversationId` runs in. */
|
|
463
526
|
resolveCwd(conversationId) {
|
|
464
527
|
if (conversationId) {
|
|
@@ -492,7 +555,14 @@ var PortalDaemon = class {
|
|
|
492
555
|
/** Append a line to the daemon log file (best-effort, for post-hoc debugging). */
|
|
493
556
|
logError(context, error) {
|
|
494
557
|
const message = error instanceof Error ? error.message : String(error);
|
|
495
|
-
|
|
558
|
+
this.logLine(`${context}: ${message}`);
|
|
559
|
+
}
|
|
560
|
+
/** Append an informational line to the daemon log file (best-effort). */
|
|
561
|
+
logInfo(message) {
|
|
562
|
+
this.logLine(message);
|
|
563
|
+
}
|
|
564
|
+
logLine(message) {
|
|
565
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${message}\n`;
|
|
496
566
|
appendFile(this.paths.logPath, line).catch((_error) => {});
|
|
497
567
|
}
|
|
498
568
|
/** Close the listener without exiting the process (tests own the process). */
|
|
@@ -516,6 +586,7 @@ var PortalDaemon = class {
|
|
|
516
586
|
v: LOCAL_PROTOCOL_VERSION,
|
|
517
587
|
pid: process.pid,
|
|
518
588
|
appUrl: this.appUrl,
|
|
589
|
+
build: portalDaemonBuild(),
|
|
519
590
|
portal: {
|
|
520
591
|
status: portal?.status ?? "off",
|
|
521
592
|
machineName: portal?.machineName ?? "",
|
|
@@ -551,6 +622,7 @@ var PortalDaemon = class {
|
|
|
551
622
|
if (Array.isArray(parsed.declined)) {
|
|
552
623
|
for (const id of parsed.declined) if (typeof id === "string") this.declined.add(id);
|
|
553
624
|
}
|
|
625
|
+
if (typeof parsed.machineName === "string" && parsed.machineName) this.persistedMachineName = parsed.machineName;
|
|
554
626
|
}
|
|
555
627
|
} catch (_error) {}
|
|
556
628
|
}
|
|
@@ -558,7 +630,8 @@ var PortalDaemon = class {
|
|
|
558
630
|
const state = {
|
|
559
631
|
version: LOCAL_PROTOCOL_VERSION,
|
|
560
632
|
cwds: Object.fromEntries(this.cwds),
|
|
561
|
-
declined: [...this.declined]
|
|
633
|
+
declined: [...this.declined],
|
|
634
|
+
machineName: this.persistedMachineName ?? void 0
|
|
562
635
|
};
|
|
563
636
|
writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
|
|
564
637
|
this.logError("persistState failed", error);
|
|
@@ -573,10 +646,23 @@ var PortalDaemon = class {
|
|
|
573
646
|
* before dispatching a normal command — the same pattern the update-check
|
|
574
647
|
* worker uses, so it survives bundling (import.meta.url points at the bundle,
|
|
575
648
|
* not a standalone daemon module).
|
|
649
|
+
*
|
|
650
|
+
* Newest build wins: the daemon is embedded in two independently-updated
|
|
651
|
+
* installers (the skydive CLI and the desktop app), and whoever spawned first
|
|
652
|
+
* would otherwise hold the socket forever — a stale desktop daemon serving a
|
|
653
|
+
* newer CLI indefinitely. So when a daemon is already listening, compare its
|
|
654
|
+
* build stamp to ours: if ours is strictly newer, ask it to shut down (it
|
|
655
|
+
* drains attached clients, who reconnect within ~500ms) and spawn from this
|
|
656
|
+
* binary. Both spawners apply the same rule, so a host converges on the
|
|
657
|
+
* newest installed build with at most one bounce.
|
|
576
658
|
*/
|
|
577
659
|
async function ensureDaemonRunning(appUrl) {
|
|
578
660
|
const { socketPath } = daemonPaths(appUrl);
|
|
579
|
-
if (await isDaemonListening(socketPath))
|
|
661
|
+
if (await isDaemonListening(socketPath)) {
|
|
662
|
+
const status = await queryDaemonStatus(appUrl);
|
|
663
|
+
if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
|
|
664
|
+
if (await stopDaemon(appUrl) === "failed") return;
|
|
665
|
+
}
|
|
580
666
|
const entry = process.argv[1];
|
|
581
667
|
const args = entry ? [
|
|
582
668
|
entry,
|