skydive-cli 0.5.0-beta.9 → 0.6.0-beta.3
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 +13 -0
- package/README.md +61 -13
- package/dist/js/api-BFQ4PQDA.mjs +315 -0
- package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
- package/dist/js/bin.mjs +674 -307
- package/dist/js/{boot-Q-Kh3nn5.mjs → boot-CleHADz9.mjs} +4558 -930
- package/dist/js/chunk-BbwQpWto.mjs +33 -0
- package/dist/js/{client-c4c5MmgN.mjs → client-Btq6bMzX.mjs} +108 -2
- package/dist/js/{client-DabRpc_T.mjs → client-CErCpXl9.mjs} +437 -39
- package/dist/js/client-qY4nTj2Y.mjs +5 -0
- package/dist/js/daemon-BuTGs-Ns.mjs +7 -0
- package/dist/js/{daemon-D21wQ7DI.mjs → daemon-Cuy4S6Rw.mjs} +238 -50
- package/dist/js/daemon-client-BVX-rWfX.mjs +8 -0
- package/dist/js/{daemon-client-DPUNjhBB.mjs → daemon-client-b10uck-j.mjs} +1 -1
- package/dist/js/dist-CRtjM7ba.mjs +1750 -0
- package/dist/js/forward-C-f04uyE.mjs +208 -0
- package/dist/js/{profiler-BkCV__ao.mjs → install-D7MwRv0n.mjs} +545 -215
- package/dist/js/launcher.mjs +49 -0
- package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
- package/dist/js/{print-BpuyEfWX.mjs → print-B3yhPm0N.mjs} +257 -35
- package/dist/js/{print-Wakr3GJd.mjs → print-VulYMc7x.mjs} +3 -3
- package/dist/js/{print-share-CKLPmsg0.mjs → print-share-DfTHn1HL.mjs} +9 -3
- package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
- package/dist/js/{raw-pty-DY4KelZW.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
- package/dist/js/rest-CRRpVyot.mjs +6 -0
- package/dist/js/{rest-I3imNduB.mjs → rest-ChXt8YGA.mjs} +144 -19
- package/dist/js/tls-cert-BpCaD5AT.mjs +4 -0
- package/dist/js/tls-cert-Rua2oV7n.mjs +67 -0
- package/package.json +14 -6
- package/dist/js/api-DG5W6iwx.mjs +0 -131
- package/dist/js/client-BuU34IVE.mjs +0 -5
- package/dist/js/daemon-LSDSvMaC.mjs +0 -6
- package/dist/js/daemon-client-CUSq-Wuh.mjs +0 -7
- package/dist/js/forward-18QoL5dO.mjs +0 -68
- package/dist/js/raw-pty-DmdUf4_w.mjs +0 -5
- package/dist/js/rest-D29qNkto.mjs +0 -6
- /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
- /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
- /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- The chat TUI status bar now always shows where the portal stands, instead of only lighting up once everything already worked. It reads `portal off`, `portal connecting`, `portal on` (open on this machine, but this agent has no grant), `portal shared` (green — this agent can run commands here), `portal error`, or `portal unavailable` when you signed in with an API key and no portal is possible. Previously the badge rendered nothing in every one of those states, so "why can't the agent reach my machine?" had no answer on screen.
|
|
13
|
+
- One word for that feature across the whole CLI: **portal**. The footer hint is now `ctrl+t portal` / `ctrl+t close portal` (was `local access` / `revoke access`), `/status` reports a `portal` row (was `local access`), and `/share` and `/portal` describe themselves as opening the portal and granting an agent access to your machine. `/portal` used to say "access your computer", which collided with `/computer` — that command still means the **agent's** sandbox, and "computer" stays reserved for it. `--share-machine` and the `shareMachineDefault` config key are unchanged.
|
|
14
|
+
- `npm i -g skydive-cli` (and yarn/pnpm/bun) now installs a tiny launcher plus one prebuilt, os/cpu-matched binary that carries its own runtime — so on the common platforms `skydive chat` and `skydive import` start instantly and **do not download a Bun runtime on first use**. On any platform without a prebuilt binary (or if the optional dependency is skipped), the CLI automatically falls back to the existing behavior: it runs under your Node and provisions Bun on first `chat`, exactly as before. Nothing about the commands, flags, config, or `SKYDIVE_*` environment variables changes, and `skydive update` still upgrades npm installs via your package manager and standalone-binary installs in place.
|
|
15
|
+
|
|
10
16
|
### Added
|
|
11
17
|
|
|
18
|
+
- Files an agent shares into the conversation now show as a saveable row in the chat TUI. When the agent sends you a file (a report, an export, a screenshot), it appears as a `📎` row with the filename and size and a "click to save" hint; clicking it downloads the file to your `~/Downloads` (falling back to your home directory) and opens it with your default app, then the row shows where it landed. Clicking a saved row again re-opens it. Previously these files were only reachable in the web app — in the terminal they were invisible.
|
|
19
|
+
- `platform ask` forms now render in the chat TUI. The card lists each question with lettered options (A/B/C/D plus Other); `ctrl+r` opens a full overlay picker (one question at a time, arrows + enter, Other as a typed row — the Claude Code interaction). The answer sends as a normal message. Headless `chat -p` prints the question as an `[action needed]` block instead of dropping it.
|
|
20
|
+
- Every API request now reports this install's version (`x-skydive-cli-version`) so the server can refuse features the installed CLI cannot render — `platform ask` needs the #21411 canary (`0.5.0-beta.43` or newer), not 0.4.0 and not earlier 0.5.0 betas.
|
|
21
|
+
- `/search` (alias `/find`) in the chat TUI — opens the conversation picker with its search field focused, so "find a conversation" has a discoverable command instead of being a feature you had to know the bare picker already had. Pass a query inline (`/search billing deep dive`) and the picker opens already narrowed to the matches; bare `/search` opens it ready for you to type. The search runs server-side across conversation titles, message bodies, and participant names, the same search the picker exposes.
|
|
22
|
+
- First run in a workspace with no agents now sets you up automatically: instead of dropping you on an empty agent picker, `skydive` creates your first agent for you (with a suggested name and avatar, mirroring the web onboarding flow) and then offers to have it learn how you already work — reading the other coding agents set up on your machine (Claude Code, Cursor, Codex, and more), the same import that `skydive import` runs, with a plan shown first and credentials never copied. Decline and you go straight into the new agent's chat. You can replay this flow anytime with `skydive --onboarding` (or `SKYDIVE_FORCE_ONBOARDING=1`), even in a workspace that already has agents.
|
|
23
|
+
- `/computer` in the chat TUI — a snapshot of the agent's sandbox drawn as colored bar graphs: CPU, memory, and disk usage. Bars turn amber past 75%% and red past 90%% so a glance tells you whether the box is under pressure. The numbers come from the Skydive API (the same live resource sample the web computer view reads), not by connecting to the sandbox — so the card shows instantly, never waking or hanging on a box that isn't running: when the agent has no live sandbox it simply reads "no sandbox running". The card keeps itself current quietly (a slow background refresh, no flicker or "updating" chrome); only the most recent card refreshes, and it silently stops while you're scrolled up (no wasted polls on a card you're not looking at) and resumes at the bottom.
|
|
24
|
+
- `/computer restart` in the chat TUI — reboot the agent's sandbox from chat. It asks a `y`/`n` confirm first (the restart stops whatever run is going and brings up a fresh box), then enqueues the same restart the web "Restart" control does. Available to anyone with edit access to the agent; view-only members are told they can't.
|
|
12
25
|
- `skydive fs` — a namespace for working with the files on an agent's computer (its sandbox) from your own machine. Its first subcommand, `skydive fs edit <path>`, reads a remote file down over the same relay `skydive sandbox` uses, opens it in your local editor (`$VISUAL`/`$EDITOR`, override with `--editor`, defaults to `vi`), and writes your changes back atomically when you save and close — so you can edit a remote file as if it were local without opening the chat TUI. A path that doesn't exist yet is created on save; no changes means nothing is written. Access is gated on EDIT permission for the agent, exactly like `skydive sandbox`. (A first-class `fs` relay channel and `skydive fs mount` land in follow-ups.)
|
|
13
26
|
|
|
14
27
|
## [0.4.0] - 2026-08-07
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npx skydive-cli --help
|
|
|
21
21
|
**Standalone binary (no Node required):**
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
curl -fsSL https://skydive.
|
|
24
|
+
curl -fsSL https://skydive.sh | sh
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Installs a self-contained `skydive` to `~/.local/bin`. The installer detects
|
|
@@ -86,7 +86,7 @@ The flow doesn't require a TTY: in a non-interactive shell (an agent harness,
|
|
|
86
86
|
SSH without a display) `auth login` still prints the verification URL + code
|
|
87
87
|
and waits — open the link in any browser to approve. For unattended CI, skip
|
|
88
88
|
the wait entirely and pass an existing key (mint one at
|
|
89
|
-
`skydive.com/settings/
|
|
89
|
+
`skydive.com/settings/workspace`):
|
|
90
90
|
|
|
91
91
|
```sh
|
|
92
92
|
skydive auth login --api-key sky_live_… # or set SKYDIVE_API_KEY
|
|
@@ -261,6 +261,16 @@ internal API. On launch it walks you through:
|
|
|
261
261
|
formatting, collapsed reasoning, and rich rendering of tool calls
|
|
262
262
|
(`bash`, `edit`/`write` diffs, `read`, `grep`/`glob`).
|
|
263
263
|
|
|
264
|
+
The chat TUI remembers the last agent you talked to: every conversation it
|
|
265
|
+
opens records that agent as your default, and the next bare `skydive chat`
|
|
266
|
+
drops straight into a new conversation with it — no picker. An explicit
|
|
267
|
+
`--agent` or `--resume` still wins for that invocation, and the conversation
|
|
268
|
+
it opens sets the default like any other. You can also seed or override the
|
|
269
|
+
default by hand with `skydive config set defaultAgent <id|slug|name>`. A
|
|
270
|
+
stored value that no longer matches (renamed or archived agent) falls back
|
|
271
|
+
to the agent picker with a notice, and picking an agent there heals it
|
|
272
|
+
automatically.
|
|
273
|
+
|
|
264
274
|
You can change conversation without leaving chat. `/conversation` (aliases
|
|
265
275
|
`/conversations`, `/conv`) reopens the conversation list, and
|
|
266
276
|
`/conversation <id>` — an id, its `/c/<id>` link, or a pasted
|
|
@@ -371,19 +381,36 @@ Messages typed while a response is streaming are **queued** and sent in
|
|
|
371
381
|
order as each run finishes; `ctrl+c` cancels the current run and clears the
|
|
372
382
|
queue.
|
|
373
383
|
|
|
374
|
-
###
|
|
384
|
+
### The portal: running commands on your machine (`--share-machine` / `ctrl+t`)
|
|
375
385
|
|
|
376
|
-
The
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
The **portal** is how an agent runs commands **on your own machine** — so it can
|
|
387
|
+
read the repo you launched from, run local tooling, and so on. (Not to be
|
|
388
|
+
confused with the agent's own computer, its sandbox, which `/computer` and
|
|
389
|
+
`skydive sandbox` reach.) The portal is off by default (default-deny) and you
|
|
390
|
+
opt in explicitly:
|
|
379
391
|
|
|
380
392
|
- `skydive chat --share-machine` enables it at launch, or
|
|
381
|
-
- `ctrl+t`
|
|
382
|
-
when on, `revoke access` when off), or
|
|
393
|
+
- `ctrl+t` opens and closes it from the chat screen, or
|
|
383
394
|
- set `"shareMachineDefault": true` in the CLI config file to share on every
|
|
384
395
|
launch (see [Configuration](#configuration); `--no-share-machine` overrides
|
|
385
396
|
once).
|
|
386
397
|
|
|
398
|
+
The status bar always says where the portal stands, so you never have to guess
|
|
399
|
+
why an agent can't reach you:
|
|
400
|
+
|
|
401
|
+
| badge | meaning |
|
|
402
|
+
| -------------------- | ------------------------------------------------------------------------------------ |
|
|
403
|
+
| `portal off` | not open on this machine — `ctrl+t` opens it |
|
|
404
|
+
| `portal connecting` | dialing |
|
|
405
|
+
| `portal on` | open, but the agent you're chatting with has no grant |
|
|
406
|
+
| `portal shared` | open **and** this agent can run commands here |
|
|
407
|
+
| `portal error` | the connection is broken; `/portal` shows why |
|
|
408
|
+
| `portal unavailable` | this session can't open one (API-key auth — sign in with `skydive auth login --web`) |
|
|
409
|
+
|
|
410
|
+
The two facts are separate on purpose: the connection belongs to the machine,
|
|
411
|
+
the grant belongs to one agent. `portal on` means the plumbing is up and this
|
|
412
|
+
particular agent still isn't in.
|
|
413
|
+
|
|
387
414
|
What to know before enabling it:
|
|
388
415
|
|
|
389
416
|
- **Per-agent.** The grant is scoped to the agent you're chatting with; other
|
|
@@ -412,7 +439,7 @@ What to know before enabling it:
|
|
|
412
439
|
agents can run, but an ungranted agent still needs `skydive portal grant`
|
|
413
440
|
(or an approved request).
|
|
414
441
|
|
|
415
|
-
### Headless
|
|
442
|
+
### Headless portal access (`skydive portal`)
|
|
416
443
|
|
|
417
444
|
`skydive portal` manages machine sharing without the TUI, for scripts, CI-ish
|
|
418
445
|
environments, and driving agents from other tools:
|
|
@@ -463,15 +490,35 @@ skydive config path # print the config.json location
|
|
|
463
490
|
|
|
464
491
|
Editable keys:
|
|
465
492
|
|
|
466
|
-
| Key | Type | Effect
|
|
467
|
-
| --------------------- | ------- |
|
|
468
|
-
| `shareMachineDefault` | boolean | When `true`, `skydive chat` (TUI and `-p`) shares this machine over the portal on launch, as if `--share-machine` were passed. An explicit `--share-machine`/`--no-share-machine` overrides it per invocation. Default `false`.
|
|
469
|
-
| `updateCheck` | boolean | When `false`, disables the daily background update check and its "Update available" notice. The persistent equivalent of setting `SKYDIVE_NO_UPDATE_CHECK` (or `NO_UPDATE_NOTIFIER`) in the environment. Default `true`.
|
|
493
|
+
| Key | Type | Effect |
|
|
494
|
+
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
495
|
+
| `shareMachineDefault` | boolean | When `true`, `skydive chat` (TUI and `-p`) shares this machine over the portal on launch, as if `--share-machine` were passed. An explicit `--share-machine`/`--no-share-machine` overrides it per invocation. Default `false`. |
|
|
496
|
+
| `updateCheck` | boolean | When `false`, disables the daily background update check and its "Update available" notice. The persistent equivalent of setting `SKYDIVE_NO_UPDATE_CHECK` (or `NO_UPDATE_NOTIFIER`) in the environment. Default `true`. |
|
|
497
|
+
| `defaultAgent` | string | Agent (id, slug, or name) a bare `skydive chat` opens a new conversation with, skipping the agent picker. Maintained automatically: every conversation the chat TUI opens records its agent here (last used wins). `skydive config set defaultAgent` seeds or overrides it; an explicit `--agent`/`--resume` wins per invocation; a value that no longer matches falls back to the picker. |
|
|
470
498
|
|
|
471
499
|
The remaining keys (`apiKey`, `apiUrl`, `sessionToken`, `appUrl`, `themeDark`,
|
|
472
500
|
`themeLight`, …) are credentials or flow-managed state — leave them to the
|
|
473
501
|
CLI.
|
|
474
502
|
|
|
503
|
+
## Diagnostic logs
|
|
504
|
+
|
|
505
|
+
The CLI records local diagnostics by default. It keeps the latest 20 sessions
|
|
506
|
+
beside the CLI config. Nothing uploads automatically.
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
skydive logs # list recent sessions
|
|
510
|
+
skydive logs path # print the latest session directory
|
|
511
|
+
skydive logs export # archive the latest session for sharing
|
|
512
|
+
skydive logs export <session> # archive one session from `logs list`
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Automatic logs include request timing, TUI state changes, render performance,
|
|
516
|
+
and process metadata. Command values and URL query values are removed.
|
|
517
|
+
|
|
518
|
+
Set `SKYDIVE_PROFILE=<path>` to write a full profile to a chosen directory.
|
|
519
|
+
Full profiles also include CPU data. Set `SKYDIVE_PROFILE=0` to disable all
|
|
520
|
+
session diagnostics.
|
|
521
|
+
|
|
475
522
|
## Environment variables
|
|
476
523
|
|
|
477
524
|
| Variable | Effect |
|
|
@@ -482,6 +529,7 @@ CLI.
|
|
|
482
529
|
| `SKYDIVE_APP_URL` | Chat app/session origin (overrides stored value) |
|
|
483
530
|
| `SKYDIVE_THEME` | Chat colorscheme id (flag `--theme` overrides) |
|
|
484
531
|
| `SKYDIVE_CONFIG_NAME` | On-disk config profile name (default `skydive`) |
|
|
532
|
+
| `SKYDIVE_PROFILE` | Full profile path; `0` disables diagnostics |
|
|
485
533
|
|
|
486
534
|
## Testing the TUI
|
|
487
535
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as HttpError } from "./http-error-BF2NZZE3.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import http2 from "node:http2";
|
|
6
|
+
|
|
7
|
+
//#region ../portal-daemon/src/tls-forward.ts
|
|
8
|
+
const TLS_PORT_OFFSET = 1e4;
|
|
9
|
+
const STRIP_HEADERS = new Set([
|
|
10
|
+
"connection",
|
|
11
|
+
"keep-alive",
|
|
12
|
+
"proxy-authenticate",
|
|
13
|
+
"proxy-authorization",
|
|
14
|
+
"proxy-connection",
|
|
15
|
+
"te",
|
|
16
|
+
"trailer",
|
|
17
|
+
"transfer-encoding",
|
|
18
|
+
"upgrade",
|
|
19
|
+
"http2-settings"
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The opt-in TLS+h2 origin for a forwarded/exposed port (ANY-6872, additive
|
|
23
|
+
* next to the plain listener — never a replacement). Browsers only negotiate
|
|
24
|
+
* h2 over TLS, and h2's stream multiplexing is what lifts the
|
|
25
|
+
* 6-connections-per-origin cap that makes unbundled dev servers crawl through
|
|
26
|
+
* the tunnel; each h2 stream is converted here to an h1 request over a tunnel
|
|
27
|
+
* from `acquireTunnel`. Used by BOTH tunnel surfaces: the CLI's
|
|
28
|
+
* `skydive portal forward` and the desktop daemon's `platform portal expose`.
|
|
29
|
+
*
|
|
30
|
+
* The dev server keeps seeing `Host: localhost:<port>` — the browser's
|
|
31
|
+
* authority is a public name it would reject (Vite 403s unknown hosts), and
|
|
32
|
+
* "looks exactly like localhost" is the product contract. The original
|
|
33
|
+
* authority travels in x-forwarded-host / x-forwarded-proto for apps that
|
|
34
|
+
* want it.
|
|
35
|
+
*/
|
|
36
|
+
async function startTlsForward({ cert, localPort, targetPort, acquireTunnel, log }) {
|
|
37
|
+
const connectionShims = {
|
|
38
|
+
setNoDelay: () => {},
|
|
39
|
+
setKeepAlive: () => {},
|
|
40
|
+
setTimeout: () => {},
|
|
41
|
+
ref: () => {},
|
|
42
|
+
unref: () => {}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* node's http client drives the tunnel: the agent's keep-alive free list
|
|
46
|
+
* gives request→request socket reuse on top of the pool's pre-paid dials,
|
|
47
|
+
* and a tunnel the daemon/dev-server side reaps while idle is evicted from
|
|
48
|
+
* the free list by its 'close' — the next request just adopts a fresh one.
|
|
49
|
+
* maxSockets mirrors the pool cap: every socket here holds an edge WS open,
|
|
50
|
+
* and ANY-6873 is the honest fix for wanting more concurrency than that.
|
|
51
|
+
*
|
|
52
|
+
* The base signature allows synchronous implementations to return the
|
|
53
|
+
* socket; this one is asynchronous, so it always uses the callback and
|
|
54
|
+
* returns undefined (the documented async contract). The agent core always
|
|
55
|
+
* passes the callback — the error branch below guards the contract anyway
|
|
56
|
+
* rather than assuming it.
|
|
57
|
+
*/
|
|
58
|
+
class TunnelAgent extends http.Agent {
|
|
59
|
+
createConnection(_options, callback) {
|
|
60
|
+
if (!callback) throw new Error("TunnelAgent.createConnection requires the async callback");
|
|
61
|
+
(async () => {
|
|
62
|
+
let tunnel;
|
|
63
|
+
try {
|
|
64
|
+
tunnel = await acquireTunnel();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
Object.assign(tunnel.stream, connectionShims);
|
|
70
|
+
tunnel.stream.resume();
|
|
71
|
+
callback(null, tunnel.stream);
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const agent = new TunnelAgent({
|
|
76
|
+
keepAlive: true,
|
|
77
|
+
maxSockets: 16
|
|
78
|
+
});
|
|
79
|
+
const server = http2.createSecureServer({
|
|
80
|
+
key: cert.key,
|
|
81
|
+
cert: cert.cert,
|
|
82
|
+
allowHTTP1: true
|
|
83
|
+
});
|
|
84
|
+
const liveSessions = /* @__PURE__ */ new Set();
|
|
85
|
+
server.on("session", (session) => {
|
|
86
|
+
liveSessions.add(session);
|
|
87
|
+
session.once("close", () => liveSessions.delete(session));
|
|
88
|
+
});
|
|
89
|
+
server.on("secureConnection", (socket) => {
|
|
90
|
+
liveSessions.add(socket);
|
|
91
|
+
socket.once("close", () => liveSessions.delete(socket));
|
|
92
|
+
});
|
|
93
|
+
server.on("request", (req, res) => {
|
|
94
|
+
const headers = {};
|
|
95
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
96
|
+
if (name.startsWith(":") || STRIP_HEADERS.has(name)) continue;
|
|
97
|
+
if (value !== void 0) headers[name] = value;
|
|
98
|
+
}
|
|
99
|
+
headers.host = `localhost:${targetPort}`;
|
|
100
|
+
headers["x-forwarded-proto"] = "https";
|
|
101
|
+
headers["x-forwarded-host"] = cert.hostname;
|
|
102
|
+
const upstream = http.request({
|
|
103
|
+
host: "localhost",
|
|
104
|
+
port: targetPort,
|
|
105
|
+
method: req.method,
|
|
106
|
+
path: req.url,
|
|
107
|
+
headers,
|
|
108
|
+
agent
|
|
109
|
+
}, (upstreamRes) => {
|
|
110
|
+
const resHeaders = {};
|
|
111
|
+
for (const [name, value] of Object.entries(upstreamRes.headers)) {
|
|
112
|
+
if (STRIP_HEADERS.has(name)) continue;
|
|
113
|
+
if (value !== void 0) resHeaders[name] = value;
|
|
114
|
+
}
|
|
115
|
+
res.writeHead(upstreamRes.statusCode ?? 502, resHeaders);
|
|
116
|
+
upstreamRes.pipe(res);
|
|
117
|
+
upstreamRes.on("error", () => res.destroy());
|
|
118
|
+
});
|
|
119
|
+
upstream.on("error", (err) => {
|
|
120
|
+
log(`forward(tls): upstream request failed: ${err.message}`);
|
|
121
|
+
if (!res.headersSent) {
|
|
122
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
123
|
+
res.end("portal forward: tunnel request failed");
|
|
124
|
+
} else res.destroy();
|
|
125
|
+
});
|
|
126
|
+
req.pipe(upstream);
|
|
127
|
+
req.on("error", () => upstream.destroy());
|
|
128
|
+
});
|
|
129
|
+
server.on("upgrade", (req, socket, head) => {
|
|
130
|
+
socket.pause();
|
|
131
|
+
(async () => {
|
|
132
|
+
let tunnel;
|
|
133
|
+
try {
|
|
134
|
+
tunnel = await acquireTunnel();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log(`forward(tls): upgrade tunnel failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
socket.destroy();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
141
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
142
|
+
const name = req.rawHeaders[i];
|
|
143
|
+
const value = name?.toLowerCase() === "host" ? `localhost:${targetPort}` : req.rawHeaders[i + 1];
|
|
144
|
+
lines.push(`${name}: ${value}`);
|
|
145
|
+
}
|
|
146
|
+
tunnel.stream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
147
|
+
if (head.length > 0) tunnel.stream.write(head);
|
|
148
|
+
tunnel.stream.on("error", () => socket.destroy());
|
|
149
|
+
tunnel.stream.on("close", () => socket.destroy());
|
|
150
|
+
socket.on("error", () => tunnel.stream.destroy());
|
|
151
|
+
socket.on("close", () => tunnel.stream.destroy());
|
|
152
|
+
socket.pipe(tunnel.stream).pipe(socket);
|
|
153
|
+
socket.resume();
|
|
154
|
+
})();
|
|
155
|
+
});
|
|
156
|
+
server.on("tlsClientError", (err, tlsSocket) => {
|
|
157
|
+
tlsSocket.destroy();
|
|
158
|
+
log(`forward(tls): handshake failed: ${err.message}`);
|
|
159
|
+
});
|
|
160
|
+
const preferredPort = localPort + TLS_PORT_OFFSET <= 65535 ? localPort + TLS_PORT_OFFSET : 0;
|
|
161
|
+
return {
|
|
162
|
+
port: await new Promise((resolve, reject) => {
|
|
163
|
+
const onError = (err) => {
|
|
164
|
+
if (err.code === "EADDRINUSE" && preferredPort !== 0) {
|
|
165
|
+
server.listen(0, "127.0.0.1");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
server.removeListener("error", onError);
|
|
169
|
+
reject(err);
|
|
170
|
+
};
|
|
171
|
+
server.on("error", onError);
|
|
172
|
+
server.on("listening", () => {
|
|
173
|
+
server.removeListener("error", onError);
|
|
174
|
+
const addr = server.address();
|
|
175
|
+
resolve(addr && typeof addr === "object" ? addr.port : preferredPort);
|
|
176
|
+
});
|
|
177
|
+
server.listen(preferredPort, "127.0.0.1");
|
|
178
|
+
}),
|
|
179
|
+
hostname: cert.hostname,
|
|
180
|
+
close: () => new Promise((resolve) => {
|
|
181
|
+
agent.destroy();
|
|
182
|
+
server.close(() => resolve());
|
|
183
|
+
for (const live of liveSessions) live.destroy();
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region ../portal-daemon/src/api.ts
|
|
190
|
+
/**
|
|
191
|
+
* The portal's session-authed REST surface, shared by `PortalClient` (the
|
|
192
|
+
* TUI/`portal open` connection) and the `skydive portal` management
|
|
193
|
+
* commands, so the endpoint contracts and response schemas live in exactly
|
|
194
|
+
* one place.
|
|
195
|
+
*/
|
|
196
|
+
const deviceSchema = z.object({
|
|
197
|
+
id: z.string(),
|
|
198
|
+
machineName: z.string(),
|
|
199
|
+
friendlyName: z.string(),
|
|
200
|
+
connected: z.boolean(),
|
|
201
|
+
lastSeen: z.string().nullable(),
|
|
202
|
+
grantedAgentIds: z.array(z.string())
|
|
203
|
+
});
|
|
204
|
+
const devicesResponseSchema = z.object({
|
|
205
|
+
devices: z.array(deviceSchema),
|
|
206
|
+
agents: z.array(z.object({
|
|
207
|
+
id: z.string(),
|
|
208
|
+
name: z.string()
|
|
209
|
+
}))
|
|
210
|
+
});
|
|
211
|
+
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
212
|
+
async function portalFetch(auth, path, init) {
|
|
213
|
+
const res = await fetch(`${auth.appUrl}${path}`, {
|
|
214
|
+
method: init.method,
|
|
215
|
+
headers: {
|
|
216
|
+
authorization: `Bearer ${auth.sessionToken}`,
|
|
217
|
+
accept: "application/json",
|
|
218
|
+
...init.body ? { "content-type": "application/json" } : {}
|
|
219
|
+
},
|
|
220
|
+
...init.body ? { body: init.body } : {}
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const body = await res.text().catch(() => "");
|
|
224
|
+
throw new HttpError(res.status, body);
|
|
225
|
+
}
|
|
226
|
+
return res.json();
|
|
227
|
+
}
|
|
228
|
+
async function fetchPortalDevices(auth) {
|
|
229
|
+
const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
|
|
230
|
+
return devicesResponseSchema.parse(json);
|
|
231
|
+
}
|
|
232
|
+
const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
|
|
233
|
+
/**
|
|
234
|
+
* Register this machine's device row without connecting. Connecting registers
|
|
235
|
+
* as a side effect; this covers granting an agent on a machine that has never
|
|
236
|
+
* shared yet (the grant references the device row).
|
|
237
|
+
*/
|
|
238
|
+
async function registerPortalDevice(auth, { machineName, friendlyName }) {
|
|
239
|
+
const json = await portalFetch(auth, "/api/v1/portal/devices", {
|
|
240
|
+
method: "POST",
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
machineName,
|
|
243
|
+
friendlyName
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
return registerResponseSchema.parse(json).device;
|
|
247
|
+
}
|
|
248
|
+
/** Short-lived token the machine presents when dialing the portal WebSocket. */
|
|
249
|
+
async function mintPortalDeviceToken(auth) {
|
|
250
|
+
const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
|
|
251
|
+
return deviceTokenSchema.parse(json).token;
|
|
252
|
+
}
|
|
253
|
+
const forwardTargetSchema = z.object({
|
|
254
|
+
daemonOrigin: z.string().min(1),
|
|
255
|
+
token: z.string().min(1),
|
|
256
|
+
expiresInSeconds: z.number()
|
|
257
|
+
});
|
|
258
|
+
/**
|
|
259
|
+
* Everything `portal forward` needs to dial an agent's sandbox daemon through
|
|
260
|
+
* the agent-webserver edge Worker: the daemon's stable public origin and a
|
|
261
|
+
* daemon auth token (canUse-gated server-side).
|
|
262
|
+
*/
|
|
263
|
+
async function fetchForwardTarget(auth, agentId) {
|
|
264
|
+
const json = await portalFetch(auth, "/api/v1/portal/forward-target", {
|
|
265
|
+
method: "POST",
|
|
266
|
+
body: JSON.stringify({ agentId })
|
|
267
|
+
});
|
|
268
|
+
return forwardTargetSchema.parse(json);
|
|
269
|
+
}
|
|
270
|
+
async function grantPortalAccess(auth, { deviceId, agentId, conversationId }) {
|
|
271
|
+
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
agentId,
|
|
275
|
+
conversationId
|
|
276
|
+
})
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async function revokePortalAccess(auth, { deviceId, agentId }) {
|
|
280
|
+
await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The device row for a given machine identity. Matching is by `machineName`
|
|
284
|
+
* equality — the stable handle the machine registers under, not the display
|
|
285
|
+
* label.
|
|
286
|
+
*/
|
|
287
|
+
function findThisDevice(devices, machineName) {
|
|
288
|
+
return devices.find((device) => device.machineName === machineName) ?? null;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* One-time grant migration onto the merged device. Earlier CLI builds
|
|
292
|
+
* registered a separate `<machineName>-cli` device, so a user's existing
|
|
293
|
+
* approvals hang off that row; the merged device would start with zero grants
|
|
294
|
+
* and every already-authorized agent would ask again. Copy any grant the
|
|
295
|
+
* merged device is missing (the grant endpoint upserts, so re-runs are
|
|
296
|
+
* no-ops). The legacy row is left in place — an old CLI build may still
|
|
297
|
+
* connect under it. Returns how many grants were copied.
|
|
298
|
+
*/
|
|
299
|
+
async function unifyLegacyCliGrants(auth, machineName) {
|
|
300
|
+
const { devices } = await fetchPortalDevices(auth);
|
|
301
|
+
const merged = findThisDevice(devices, machineName);
|
|
302
|
+
const legacy = findThisDevice(devices, `${machineName}-cli`);
|
|
303
|
+
if (!merged || !legacy) return 0;
|
|
304
|
+
const have = new Set(merged.grantedAgentIds);
|
|
305
|
+
const missing = legacy.grantedAgentIds.filter((id) => !have.has(id));
|
|
306
|
+
for (const agentId of missing) await grantPortalAccess(auth, {
|
|
307
|
+
deviceId: merged.id,
|
|
308
|
+
agentId,
|
|
309
|
+
conversationId: null
|
|
310
|
+
});
|
|
311
|
+
return missing.length;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
//#endregion
|
|
315
|
+
export { mintPortalDeviceToken as a, unifyLegacyCliGrants as c, grantPortalAccess as i, startTlsForward as l, fetchPortalDevices as n, registerPortalDevice as o, findThisDevice as r, revokePortalAccess as s, fetchForwardTarget as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as billingBlockedOutcomeSchema, i as billingBlockedOutcomeFromSendResponse, n as BillingBlockedError, r as billingBlockedJsonResult, t as BILLING_BLOCKED_EXIT_CODE } from "./billing-blocked-
|
|
2
|
+
import { a as billingBlockedOutcomeSchema, i as billingBlockedOutcomeFromSendResponse, n as BillingBlockedError, r as billingBlockedJsonResult, t as BILLING_BLOCKED_EXIT_CODE } from "./billing-blocked-D3l5kJlX.mjs";
|
|
3
3
|
|
|
4
4
|
export { BILLING_BLOCKED_EXIT_CODE, BillingBlockedError, billingBlockedJsonResult };
|