@openparachute/hub 0.5.13-rc.23 → 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.
package/README.md CHANGED
@@ -24,11 +24,14 @@ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions
24
24
 
25
25
  **Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
26
26
 
27
- After deploy:
27
+ After deploy completes:
28
28
 
29
- 1. Open your Render service URL wizard runs at `/admin/setup`
30
- 2. Set custom domain (optional)set `PARACHUTE_HUB_ORIGIN` env to match
31
- 3. Install modules via the admin SPA at `/admin/modules` (or via the wizard)
29
+ 1. Open Render Logssearch for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
30
+ 2. Visit your Render service URL's `/admin/setup` paste the token create your admin account.
31
+ 3. Set custom domain (optional) set `PARACHUTE_HUB_ORIGIN` env to match.
32
+ 4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
33
+
34
+ Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
32
35
 
33
36
  Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
34
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.23",
3
+ "version": "0.5.13-rc.29",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -117,6 +117,56 @@ describe("resolveIssuer — precedence chain", () => {
117
117
  // Pass 3 — back to request origin.
118
118
  expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
119
119
  });
120
+
121
+ test("X-Forwarded-Proto: https upgrades the request-origin fallback", () => {
122
+ // Render / Tailscale Funnel / cloudflared terminate TLS at the edge
123
+ // and forward plain HTTP. Without honoring the header, hub publishes
124
+ // `http://...` in OAuth discovery — mixed-content blocked when the
125
+ // page loaded over https://. See hub#355 (the notes app's
126
+ // /oauth/register call surfaced this).
127
+ const r = new Request("http://parachute-hub.onrender.com/.well-known/oauth-authorization-server", {
128
+ method: "GET",
129
+ headers: { "X-Forwarded-Proto": "https" },
130
+ });
131
+ expect(resolveIssuer(r, db, undefined)).toBe("https://parachute-hub.onrender.com");
132
+ });
133
+
134
+ test("X-Forwarded-Proto with comma-separated values takes the first", () => {
135
+ // Multi-hop proxies append; the leftmost is the original client → edge
136
+ // hop. RFC-style parsing (consistent with isHttpsRequest).
137
+ const r = new Request("http://hub.internal/oauth/token", {
138
+ method: "GET",
139
+ headers: { "X-Forwarded-Proto": "https, http" },
140
+ });
141
+ expect(resolveIssuer(r, db, undefined)).toBe("https://hub.internal");
142
+ });
143
+
144
+ test("missing X-Forwarded-Proto leaves the URL scheme as-is (localhost dev)", () => {
145
+ // No reverse proxy → no header → keep http for the local-dev shape.
146
+ // Operators on plain HTTP localhost depend on this.
147
+ const r = new Request("http://127.0.0.1:1939/oauth/token", { method: "GET" });
148
+ expect(resolveIssuer(r, db, undefined)).toBe("http://127.0.0.1:1939");
149
+ });
150
+
151
+ test("X-Forwarded-Proto is IGNORED when hub_settings or env wins", () => {
152
+ // Precedence guard: X-Forwarded-Proto should only affect the
153
+ // request-origin fallback branch. Explicit operator config
154
+ // (settings row, env var) always wins as-is, including its scheme.
155
+ // Without this guard, a future refactor could accidentally let the
156
+ // header override an operator's deliberate choice.
157
+ const r = new Request("http://hub.internal/oauth/token", {
158
+ method: "GET",
159
+ headers: { "X-Forwarded-Proto": "https" },
160
+ });
161
+
162
+ // Env layer wins, even though the header says https — the env value
163
+ // is returned verbatim (preserving whatever scheme the operator set).
164
+ expect(resolveIssuer(r, db, "http://configured.example")).toBe("http://configured.example");
165
+
166
+ // Settings layer wins above env, also verbatim.
167
+ setHubOrigin(db, "http://settings.example");
168
+ expect(resolveIssuer(r, db, "https://env.example")).toBe("http://settings.example");
169
+ });
120
170
  });
121
171
 
122
172
  describe("resolveIssuerSource — attribution for SPA", () => {
@@ -184,6 +184,15 @@ describe("formatBootstrapTokenBanner", () => {
184
184
  expect(line.startsWith("[wizard]")).toBe(true);
185
185
  }
186
186
  });
187
+
188
+ test("uses ═ delimiters and an ALL-CAPS heading so operators spot the block in log viewers", () => {
189
+ const banner = formatBootstrapTokenBanner("parachute-bootstrap-visual-token");
190
+ // The ═ box-drawing char is the visual cue an operator scrolling
191
+ // Render's log tab keys off; this assertion locks the new shape so
192
+ // a stylistic regression doesn't silently demote the banner.
193
+ expect(banner).toContain("═");
194
+ expect(banner).toContain("PARACHUTE BOOTSTRAP TOKEN");
195
+ });
187
196
  });
188
197
 
189
198
  // --- bootstrap-token generation under needs-setup (Issue 1 wiring) -------
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Regression test for hub#349: Bun.spawn defaults to an EMPTY env, which meant
3
+ * subprocess `bun add -g` didn't see TMPDIR, BUN_INSTALL, or any other env vars
4
+ * set by the Dockerfile / Render env. All `defaultRun`-style helpers were
5
+ * updated to pass `env: process.env`.
6
+ *
7
+ * This test asserts that property end-to-end: spawn a real child via `bun -e`
8
+ * and have it print one parent-set env var. Pre-fix, the child would not see
9
+ * the var; post-fix, it does.
10
+ *
11
+ * We exercise the production path by importing one representative helper.
12
+ * The full set of seven explicit + several inherited fix sites all use the
13
+ * same `env: process.env` pattern; testing one is sufficient to lock the
14
+ * pattern in place — the others are mechanical applications of it.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+
18
+ describe("Bun.spawn env propagation (hub#349)", () => {
19
+ test("child process sees parent env when defaultRun-style helper is used", async () => {
20
+ // Unique marker so we can't false-positive against leftover env from
21
+ // another test or the harness itself.
22
+ const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER";
23
+ const markerValue = `marker-${Date.now()}-${Math.random().toString(36).slice(2)}`;
24
+
25
+ const originalValue = process.env[markerKey];
26
+ process.env[markerKey] = markerValue;
27
+ try {
28
+ // Spawn a child the same way every defaultRun helper does:
29
+ // `env: process.env`. The child prints its view of the marker var.
30
+ const proc = Bun.spawn(
31
+ ["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
32
+ {
33
+ stdout: "pipe",
34
+ stderr: "pipe",
35
+ env: process.env,
36
+ },
37
+ );
38
+ const stdout = await new Response(proc.stdout).text();
39
+ const exitCode = await proc.exited;
40
+
41
+ expect(exitCode).toBe(0);
42
+ expect(stdout).toBe(markerValue);
43
+ } finally {
44
+ if (originalValue === undefined) delete process.env[markerKey];
45
+ else process.env[markerKey] = originalValue;
46
+ }
47
+ });
48
+
49
+ test("child process does NOT see parent env when env is omitted (negative control)", async () => {
50
+ // The bug we're guarding against: without `env: process.env`, Bun.spawn
51
+ // hands the child an empty env. This test pins the failure mode so a
52
+ // future regression (someone removing `env: process.env`) is caught here,
53
+ // not in production on Render.
54
+ const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER_NEG";
55
+ const markerValue = `marker-${Date.now()}`;
56
+
57
+ const originalValue = process.env[markerKey];
58
+ process.env[markerKey] = markerValue;
59
+ try {
60
+ const proc = Bun.spawn(
61
+ ["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
62
+ {
63
+ stdout: "pipe",
64
+ stderr: "pipe",
65
+ // intentionally NO env: process.env
66
+ },
67
+ );
68
+ const stdout = await new Response(proc.stdout).text();
69
+ const exitCode = await proc.exited;
70
+
71
+ expect(exitCode).toBe(0);
72
+ expect(stdout).toBe("MISSING");
73
+ } finally {
74
+ if (originalValue === undefined) delete process.env[markerKey];
75
+ else process.env[markerKey] = originalValue;
76
+ }
77
+ });
78
+ });
@@ -206,7 +206,12 @@ function buildEntry(
206
206
  }
207
207
 
208
208
  async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
209
- const proc = Bun.spawn([...cmd], { stdio: ["ignore", "pipe", "pipe"] });
209
+ // Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
210
+ // defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
211
+ const proc = Bun.spawn([...cmd], {
212
+ stdio: ["ignore", "pipe", "pipe"],
213
+ env: process.env,
214
+ });
210
215
  // Drain both pipes in parallel — leaving stderr unread can deadlock long
211
216
  // installs once the OS pipe buffer fills (#97). Captured stderr is folded
212
217
  // into the orchestration error message on non-zero exit.
@@ -322,7 +322,14 @@ async function resolveSpawnSpec(
322
322
  }
323
323
 
324
324
  function defaultRun(cmd: readonly string[]): Promise<number> {
325
- const proc = Bun.spawn([...cmd], { stdio: ["ignore", "inherit", "inherit"] });
325
+ // Inherit env so child `bun add` sees TMPDIR, BUN_INSTALL, PARACHUTE_*,
326
+ // etc. set by the Dockerfile / Render env. Bun.spawn defaults to empty
327
+ // env — without this, bun-add fails with cross-mount rename errors on
328
+ // Render (where TMPDIR points at the persistent disk). See hub#349.
329
+ const proc = Bun.spawn([...cmd], {
330
+ stdio: ["ignore", "inherit", "inherit"],
331
+ env: process.env,
332
+ });
326
333
  return proc.exited;
327
334
  }
328
335
 
@@ -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
@@ -167,6 +167,7 @@ import {
167
167
  } from "./oauth-handlers.ts";
168
168
  import { buildHubBoundOrigins } from "./origin-check.ts";
169
169
  import { clearPid, writePid } from "./process-state.ts";
170
+ import { isHttpsRequest } from "./request-protocol.ts";
170
171
  import {
171
172
  FIRST_PARTY_FALLBACKS,
172
173
  KNOWN_MODULES,
@@ -944,7 +945,24 @@ export function resolveIssuer(
944
945
  if (stored) return stored;
945
946
  }
946
947
  if (configuredIssuer) return configuredIssuer;
947
- 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;
948
966
  }
949
967
 
950
968
  /**
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(),