@openparachute/hub 0.5.13-rc.21 → 0.5.13-rc.29

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.
@@ -60,7 +60,13 @@ export interface Runner {
60
60
 
61
61
  export const defaultRunner: Runner = {
62
62
  async run(cmd) {
63
- const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
63
+ // Inherit env so the child (e.g. parachute-vault subprocess) sees PATH,
64
+ // HOME, PARACHUTE_HOME, etc. Bun.spawn defaults to empty env — see
65
+ // api-modules-ops.ts:defaultRun.
66
+ const proc = Bun.spawn([...cmd], {
67
+ stdio: ["inherit", "inherit", "inherit"],
68
+ env: process.env,
69
+ });
64
70
  return await proc.exited;
65
71
  },
66
72
  };
@@ -26,7 +26,12 @@ import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.
26
26
  export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
27
27
 
28
28
  const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
29
- const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
29
+ // Inherit env so subprocesses see PATH (to find `tailscale`), HOME, etc.
30
+ // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
31
+ const proc = Bun.spawn([...cmd], {
32
+ stdio: ["inherit", "inherit", "inherit"],
33
+ env: process.env,
34
+ });
30
35
  return await proc.exited;
31
36
  };
32
37
 
@@ -69,7 +69,12 @@ export const defaultCloudflaredSpawner: CloudflaredSpawner = {
69
69
  spawn(cmd, logFile) {
70
70
  mkdirSync(dirname(logFile), { recursive: true });
71
71
  const fd = openSync(logFile, "a");
72
- const proc = Bun.spawn([...cmd], { stdio: ["ignore", fd, fd] });
72
+ // Inherit env so cloudflared sees HOME (where it reads ~/.cloudflared/),
73
+ // PATH, etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
74
+ const proc = Bun.spawn([...cmd], {
75
+ stdio: ["ignore", fd, fd],
76
+ env: process.env,
77
+ });
73
78
  proc.unref();
74
79
  return proc.pid;
75
80
  },
@@ -46,7 +46,13 @@ import { type ExposeOpts, exposePublic } from "./expose.ts";
46
46
  export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
47
47
 
48
48
  const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
49
- const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
49
+ // Inherit env so interactive subprocesses (e.g. `brew install cloudflared`,
50
+ // `cloudflared tunnel login`) see PATH, HOME, etc. Bun.spawn defaults to
51
+ // empty env — see api-modules-ops.ts:defaultRun.
52
+ const proc = Bun.spawn([...cmd], {
53
+ stdio: ["inherit", "inherit", "inherit"],
54
+ env: process.env,
55
+ });
50
56
  return await proc.exited;
51
57
  };
52
58
 
@@ -250,7 +250,13 @@ export interface InstallOpts {
250
250
  }
251
251
 
252
252
  async function defaultRunner(cmd: readonly string[]): Promise<number> {
253
- const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
253
+ // Inherit env (TMPDIR, BUN_INSTALL, PATH, HOME, PARACHUTE_*, etc.) — see
254
+ // api-modules-ops.ts:defaultRun for the rationale. Same Bun.spawn-defaults-
255
+ // to-empty-env bug; same one-line fix. See hub#349.
256
+ const proc = Bun.spawn([...cmd], {
257
+ stdio: ["inherit", "inherit", "inherit"],
258
+ env: process.env,
259
+ });
254
260
  return await proc.exited;
255
261
  }
256
262
 
@@ -67,6 +67,10 @@ export const defaultSpawner: Spawner = {
67
67
  // wrapped startCmds like `pnpm exec tsx server.ts` leave the tsx
68
68
  // grandchild bound to the port after stop → restart hits EADDRINUSE.
69
69
  detached: true,
70
+ // Inherit env so child sees PATH, HOME, PARACHUTE_HOME, etc.
71
+ // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
72
+ // Per-call `opts.env` overrides merge on top below.
73
+ env: process.env,
70
74
  };
71
75
  if (opts?.env) spawnOpts.env = { ...process.env, ...opts.env };
72
76
  if (opts?.cwd) spawnOpts.cwd = opts.cwd;
@@ -647,7 +651,12 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
647
651
  if (follow) {
648
652
  const spawner = opts.tailSpawner ?? {
649
653
  spawn(cmd) {
650
- const proc = Bun.spawn([...cmd], { stdio: ["ignore", "inherit", "inherit"] });
654
+ // Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
655
+ // env — see api-modules-ops.ts:defaultRun.
656
+ const proc = Bun.spawn([...cmd], {
657
+ stdio: ["ignore", "inherit", "inherit"],
658
+ env: process.env,
659
+ });
651
660
  return proc.pid;
652
661
  },
653
662
  };
@@ -149,16 +149,26 @@ export async function seedInitialAdminIfNeeded(
149
149
  * the token can't proceed; an attacker who reads it before the
150
150
  * operator wins the race).
151
151
  */
152
- export function formatBootstrapTokenBanner(token: string): string {
152
+ export function formatBootstrapTokenBanner(token: string, hubUrl?: string): string {
153
+ const rule = "═".repeat(64);
154
+ // Substitute the actual hub URL when known (PARACHUTE_HUB_ORIGIN). Operators
155
+ // staring at the banner in Render Logs shouldn't have to figure out their
156
+ // own URL — show the literal placeholder only when the issuer isn't set.
157
+ const url = hubUrl && hubUrl.length > 0 ? hubUrl.replace(/\/+$/, "") : "<hub-url>";
153
158
  return [
154
- "[wizard] No admin exists — wizard mode active. To claim ownership of this hub:",
155
- "[wizard] 1. Visit http://localhost:1939/admin/setup (or your deployed URL)",
156
- "[wizard] 2. Paste this bootstrap token into the form:",
159
+ "[wizard]",
160
+ `[wizard] ${rule}`,
161
+ "[wizard] PARACHUTE BOOTSTRAP TOKEN",
162
+ `[wizard] ${rule}`,
157
163
  "[wizard]",
158
164
  `[wizard] ${token}`,
159
165
  "[wizard]",
160
- "[wizard] This token grants permission to create the first admin. It expires when",
161
- "[wizard] admin is created OR when hub restarts.",
166
+ `[wizard] Visit ${url}/admin/setup and paste this token to create`,
167
+ "[wizard] your admin account.",
168
+ "[wizard] → Expires when admin is created OR when hub restarts.",
169
+ "[wizard]",
170
+ `[wizard] ${rule}`,
171
+ "[wizard]",
162
172
  ].join("\n");
163
173
  }
164
174
 
@@ -199,14 +209,14 @@ export async function serve(opts: ServeOpts = {}): Promise<{
199
209
 
200
210
  if (adminBootstrap === "needs-setup") {
201
211
  log(
202
- "parachute serve: no admin account configured. Set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD, or visit /admin/setup once the hub is reachable.",
212
+ "parachute serve: no admin account configured. Visit /admin/setup once the hub is reachable, or seed via PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD env vars for scripted deploys.",
203
213
  );
204
214
  // Mint a bootstrap token + log it. The wizard's account POST will
205
215
  // require this token, so an attacker who beats the operator to the
206
216
  // freshly-provisioned URL still can't claim the admin row without
207
217
  // shell access to the platform's startup logs.
208
218
  const token = generateBootstrapToken();
209
- log(formatBootstrapTokenBanner(token));
219
+ log(formatBootstrapTokenBanner(token, issuer));
210
220
  }
211
221
 
212
222
  const supervisor = opts.supervisor ?? new Supervisor();
@@ -74,17 +74,22 @@ export interface UpgradeRunner {
74
74
 
75
75
  export const defaultRunner: UpgradeRunner = {
76
76
  async run(cmd, opts) {
77
+ // Inherit env so `bun add -g` etc. see TMPDIR, BUN_INSTALL, PATH, HOME.
78
+ // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
77
79
  const proc = Bun.spawn([...cmd], {
78
80
  cwd: opts?.cwd,
79
81
  stdio: ["inherit", "inherit", "inherit"],
82
+ env: process.env,
80
83
  });
81
84
  return await proc.exited;
82
85
  },
83
86
  async capture(cmd, opts) {
87
+ // Inherit env — same rationale as `run` above.
84
88
  const proc = Bun.spawn([...cmd], {
85
89
  cwd: opts?.cwd,
86
90
  stdout: "pipe",
87
91
  stderr: "pipe",
92
+ env: process.env,
88
93
  });
89
94
  const [stdout, stderr] = await Promise.all([
90
95
  new Response(proc.stdout).text(),
@@ -30,7 +30,13 @@ import { createInterface } from "node:readline/promises";
30
30
  export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
31
31
 
32
32
  const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
33
- const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
33
+ // Inherit env so the child (parachute-vault subprocess) sees PATH, HOME,
34
+ // PARACHUTE_HOME, etc. Bun.spawn defaults to empty env — see
35
+ // api-modules-ops.ts:defaultRun.
36
+ const proc = Bun.spawn([...cmd], {
37
+ stdio: ["inherit", "inherit", "inherit"],
38
+ env: process.env,
39
+ });
34
40
  return await proc.exited;
35
41
  };
36
42
 
@@ -2,6 +2,9 @@ export async function dispatchVault(args: readonly string[]): Promise<number> {
2
2
  try {
3
3
  const proc = Bun.spawn(["parachute-vault", ...args], {
4
4
  stdio: ["inherit", "inherit", "inherit"],
5
+ // Inherit env so parachute-vault sees PATH, HOME, PARACHUTE_HOME, etc.
6
+ // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
7
+ env: process.env,
5
8
  });
6
9
  return await proc.exited;
7
10
  } catch (err) {
@@ -74,7 +74,12 @@ export interface HubSpawner {
74
74
  export const defaultHubSpawner: HubSpawner = {
75
75
  spawn(cmd, logFile) {
76
76
  const fd = openSync(logFile, "a");
77
- const proc = Bun.spawn([...cmd], { stdio: ["ignore", fd, fd] });
77
+ // Inherit env so the hub child process sees PATH, HOME, PARACHUTE_HOME,
78
+ // etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
79
+ const proc = Bun.spawn([...cmd], {
80
+ stdio: ["ignore", fd, fd],
81
+ env: process.env,
82
+ });
78
83
  proc.unref();
79
84
  return proc.pid;
80
85
  },
package/src/hub-server.ts CHANGED
@@ -46,6 +46,7 @@
46
46
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
47
47
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
48
48
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
49
+ * /api/hub (GET) → hub version + uptime + install-source (host:admin)
49
50
  * /api/modules (GET) → curated + installed module catalog (host:auth)
50
51
  * /api/modules/channel (PUT) → operator channel toggle (host:admin)
51
52
  * /api/modules/:short/install (POST) → bun add + spawn (async op)
@@ -117,6 +118,7 @@ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
117
118
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
118
119
  import { handleCreateVault } from "./admin-vaults.ts";
119
120
  import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
121
+ import { handleApiHub } from "./api-hub.ts";
120
122
  import { handleApiMe } from "./api-me.ts";
121
123
  import { handleApiMintToken } from "./api-mint-token.ts";
122
124
  import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
@@ -165,6 +167,7 @@ import {
165
167
  } from "./oauth-handlers.ts";
166
168
  import { buildHubBoundOrigins } from "./origin-check.ts";
167
169
  import { clearPid, writePid } from "./process-state.ts";
170
+ import { isHttpsRequest } from "./request-protocol.ts";
168
171
  import {
169
172
  FIRST_PARTY_FALLBACKS,
170
173
  KNOWN_MODULES,
@@ -942,7 +945,24 @@ export function resolveIssuer(
942
945
  if (stored) return stored;
943
946
  }
944
947
  if (configuredIssuer) return configuredIssuer;
945
- return new URL(req.url).origin;
948
+ // Reverse-proxy aware: Render / Tailscale Funnel / cloudflared terminate
949
+ // TLS at the edge and forward plain HTTP to hub. Without X-Forwarded-Proto
950
+ // honoring, `req.url.origin` is `http://...` and hub publishes mixed-content
951
+ // URLs in OAuth discovery (`registration_endpoint`, `authorization_endpoint`,
952
+ // etc.) — browsers block them when the page itself loaded over https://.
953
+ // The `isHttpsRequest` helper is the canonical place where this trust
954
+ // is established (also used for the Secure cookie attribute).
955
+ //
956
+ // We do NOT honor X-Forwarded-Host. Render, Tailscale Funnel, and
957
+ // cloudflared all preserve the Host header end-to-end, so `req.url`'s
958
+ // host already reflects the public hostname. Operators on a proxy that
959
+ // rewrites Host (some nginx / Caddy configs) should set hub_origin via
960
+ // the admin SPA — that path bypasses this fallback entirely.
961
+ const url = new URL(req.url);
962
+ if (isHttpsRequest(req)) {
963
+ url.protocol = "https:";
964
+ }
965
+ return url.origin;
946
966
  }
947
967
 
948
968
  /**
@@ -1526,6 +1546,17 @@ export function hubFetch(
1526
1546
  return handleApiMe(req, { db: getDb() });
1527
1547
  }
1528
1548
 
1549
+ // Hub version + uptime + install-source — drives the admin SPA's
1550
+ // version badge (hub#348). Bearer-gated on `parachute:host:admin`
1551
+ // (same as the rest of the operator-only admin surface).
1552
+ if (pathname === "/api/hub") {
1553
+ if (!getDb) return dbNotConfigured();
1554
+ return handleApiHub(req, {
1555
+ db: getDb(),
1556
+ issuer: oauthDeps(req).issuer,
1557
+ });
1558
+ }
1559
+
1529
1560
  if (pathname === "/api/modules") {
1530
1561
  if (!getDb) return dbNotConfigured();
1531
1562
  const modulesDeps: Parameters<typeof handleApiModules>[1] = {
package/src/supervisor.ts CHANGED
@@ -403,6 +403,10 @@ async function pumpLines(
403
403
  const defaultSpawnFn: SpawnFn = (req) => {
404
404
  const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
405
405
  stdio: ["ignore", "pipe", "pipe"],
406
+ // Inherit env so supervised module sees PATH, HOME, PARACHUTE_HOME, etc.
407
+ // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
408
+ // Per-call `req.env` overrides merge on top below.
409
+ env: process.env,
406
410
  };
407
411
  if (req.cwd) spawnOpts.cwd = req.cwd;
408
412
  if (req.env) spawnOpts.env = { ...process.env, ...req.env };
@@ -7,7 +7,13 @@ export interface CommandResult {
7
7
  export type Runner = (cmd: readonly string[]) => Promise<CommandResult>;
8
8
 
9
9
  export async function defaultRunner(cmd: readonly string[]): Promise<CommandResult> {
10
- const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "pipe" });
10
+ // Inherit env so `tailscale` sees PATH (and HOME for state dir). Bun.spawn
11
+ // defaults to empty env — see api-modules-ops.ts:defaultRun for rationale.
12
+ const proc = Bun.spawn([...cmd], {
13
+ stdout: "pipe",
14
+ stderr: "pipe",
15
+ env: process.env,
16
+ });
11
17
  const [stdout, stderr, code] = await Promise.all([
12
18
  new Response(proc.stdout).text(),
13
19
  new Response(proc.stderr).text(),