skydive-cli 0.5.0-beta.1 → 0.5.0-beta.11
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-BvmABDme.mjs → boot-BWOK3tCN.mjs} +220 -70
- package/dist/js/client-BuU34IVE.mjs +5 -0
- package/dist/js/{client-Dd5sMXPv.mjs → client-DabRpc_T.mjs} +145 -112
- package/dist/js/{daemon-XCciQ1mS.mjs → daemon-CJNMbWMJ.mjs} +3 -2
- package/dist/js/{daemon-C6yadKp2.mjs → daemon-DfeT8rGL.mjs} +71 -5
- package/dist/js/daemon-client-CAkZTO_6.mjs +7 -0
- package/dist/js/{daemon-client-Rr6PJvRg.mjs → daemon-client-eBSEPljJ.mjs} +12 -8
- 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-C1LPsgTP.mjs → print-share-CKLPmsg0.mjs} +1 -1
- package/dist/js/{profiler-DHQhmoSF.mjs → profiler-BXdrd1D4.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-CKBQ8pft.mjs +0 -4
- package/dist/js/daemon-client-CgziZU3X.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,9 +1,10 @@
|
|
|
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
5
|
import { execFile, spawn } from "node:child_process";
|
|
6
|
-
import
|
|
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":
|
|
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
|
-
/**
|
|
473
|
-
|
|
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 {
|
|
653
|
+
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-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-DfeT8rGL.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-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 "1786600334";
|
|
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",
|
|
@@ -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))
|
|
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,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-DfeT8rGL.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
|
|
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.
|
|
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,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 };
|