@openparachute/hub 0.7.3-rc.7 → 0.7.3-rc.9

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * CLI wizard transcription step (onboarding-streamline hub PR1).
3
+ *
4
+ * Exercises `walkTranscriptionStep` against an injected command runner + RAM
5
+ * probe + platform — NOTHING installs and no subprocess is spawned. Covers the
6
+ * four branches the brief calls for: the CLI transcription question, the
7
+ * platform mapping, the RAM gate, and install-or-skip (mocked scribe
8
+ * subprocess).
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { walkTranscriptionStep } from "../commands/wizard-transcription.ts";
16
+ import { scribeConfigPath } from "../scribe-config.ts";
17
+
18
+ function makeHarness(): { dir: string; cleanup: () => void } {
19
+ const dir = mkdtempSync(join(tmpdir(), "pcli-wztrans-"));
20
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
21
+ }
22
+
23
+ /** Records every command the step would have spawned, returns scripted codes. */
24
+ function recordingRunner(codes: number[] = []) {
25
+ const cmds: string[][] = [];
26
+ let i = 0;
27
+ return {
28
+ cmds,
29
+ run: async (cmd: readonly string[]): Promise<number> => {
30
+ cmds.push([...cmd]);
31
+ const code = codes[i++];
32
+ return code ?? 0;
33
+ },
34
+ };
35
+ }
36
+
37
+ function readCfg(dir: string): Record<string, unknown> | undefined {
38
+ const p = scribeConfigPath(dir);
39
+ if (!existsSync(p)) return undefined;
40
+ return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
41
+ }
42
+
43
+ describe("walkTranscriptionStep — mode resolution (the CLI question)", () => {
44
+ test("none: writes no scribe config, runs no command", async () => {
45
+ const h = makeHarness();
46
+ try {
47
+ const r = recordingRunner();
48
+ const logs: string[] = [];
49
+ const code = await walkTranscriptionStep({
50
+ configDir: h.dir,
51
+ log: (l) => logs.push(l),
52
+ transcribeMode: "none",
53
+ runCommand: r.run,
54
+ platform: "linux",
55
+ });
56
+ expect(code).toBe(0);
57
+ expect(r.cmds).toEqual([]);
58
+ expect(readCfg(h.dir)).toBeUndefined();
59
+ expect(logs.join("\n")).toContain("Transcription off");
60
+ } finally {
61
+ h.cleanup();
62
+ }
63
+ });
64
+
65
+ test("interactive prompt: '1' chooses none", async () => {
66
+ const h = makeHarness();
67
+ try {
68
+ const r = recordingRunner();
69
+ const answers = ["1"];
70
+ let i = 0;
71
+ const code = await walkTranscriptionStep({
72
+ configDir: h.dir,
73
+ log: () => {},
74
+ prompt: async () => answers[i++] ?? "",
75
+ runCommand: r.run,
76
+ platform: "linux",
77
+ });
78
+ expect(code).toBe(0);
79
+ expect(r.cmds).toEqual([]);
80
+ } finally {
81
+ h.cleanup();
82
+ }
83
+ });
84
+
85
+ test("interactive prompt: '3' then 'g' chooses groq cloud", async () => {
86
+ const h = makeHarness();
87
+ try {
88
+ const r = recordingRunner([0]);
89
+ const answers = ["3", "g", "gsk_interactive"];
90
+ let i = 0;
91
+ const code = await walkTranscriptionStep({
92
+ configDir: h.dir,
93
+ log: () => {},
94
+ prompt: async () => answers[i++] ?? "",
95
+ runCommand: r.run,
96
+ platform: "linux",
97
+ });
98
+ expect(code).toBe(0);
99
+ // One install command, with the groq provider + key.
100
+ expect(r.cmds.length).toBe(1);
101
+ expect(r.cmds[0]).toEqual([
102
+ "parachute",
103
+ "install",
104
+ "scribe",
105
+ "--scribe-provider",
106
+ "groq",
107
+ "--scribe-key",
108
+ "gsk_interactive",
109
+ ]);
110
+ } finally {
111
+ h.cleanup();
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("walkTranscriptionStep — cloud (install-or-skip via the one-shot)", () => {
117
+ test("groq with a key: install one-shot carries provider + key", async () => {
118
+ const h = makeHarness();
119
+ try {
120
+ const r = recordingRunner([0]);
121
+ const code = await walkTranscriptionStep({
122
+ configDir: h.dir,
123
+ log: () => {},
124
+ transcribeMode: "groq",
125
+ transcribeApiKey: "gsk_abc123",
126
+ runCommand: r.run,
127
+ platform: "linux",
128
+ });
129
+ expect(code).toBe(0);
130
+ expect(r.cmds[0]).toEqual([
131
+ "parachute",
132
+ "install",
133
+ "scribe",
134
+ "--scribe-provider",
135
+ "groq",
136
+ "--scribe-key",
137
+ "gsk_abc123",
138
+ ]);
139
+ } finally {
140
+ h.cleanup();
141
+ }
142
+ });
143
+
144
+ test("openai without a key: install one-shot omits --scribe-key + tells operator how to add it", async () => {
145
+ const h = makeHarness();
146
+ try {
147
+ const r = recordingRunner([0]);
148
+ const logs: string[] = [];
149
+ const code = await walkTranscriptionStep({
150
+ configDir: h.dir,
151
+ log: (l) => logs.push(l),
152
+ transcribeMode: "openai",
153
+ transcribeApiKey: "",
154
+ runCommand: r.run,
155
+ platform: "darwin",
156
+ });
157
+ expect(code).toBe(0);
158
+ expect(r.cmds[0]).toEqual(["parachute", "install", "scribe", "--scribe-provider", "openai"]);
159
+ expect(logs.join("\n")).toContain("OPENAI_API_KEY");
160
+ } finally {
161
+ h.cleanup();
162
+ }
163
+ });
164
+
165
+ test("cloud install failure: surfaces the non-zero exit + a retry hint, still exits 0", async () => {
166
+ const h = makeHarness();
167
+ try {
168
+ const r = recordingRunner([1]); // install fails
169
+ const logs: string[] = [];
170
+ const code = await walkTranscriptionStep({
171
+ configDir: h.dir,
172
+ log: (l) => logs.push(l),
173
+ transcribeMode: "groq",
174
+ transcribeApiKey: "gsk_x",
175
+ runCommand: r.run,
176
+ platform: "linux",
177
+ });
178
+ expect(code).toBe(0); // non-fatal — doesn't block the wizard
179
+ expect(logs.join("\n")).toContain("returned 1");
180
+ } finally {
181
+ h.cleanup();
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("walkTranscriptionStep — local install-or-skip", () => {
187
+ test("Linux, ample RAM, install succeeds: install-backend uses onnx-asr; provider kept", async () => {
188
+ const h = makeHarness();
189
+ try {
190
+ // module install (writes provider via --scribe-provider), install-backend, restart
191
+ const r = recordingRunner([0, 0, 0]);
192
+ const code = await walkTranscriptionStep({
193
+ configDir: h.dir,
194
+ log: () => {},
195
+ transcribeMode: "local",
196
+ runCommand: r.run,
197
+ platform: "linux",
198
+ availableRamMib: 4096,
199
+ });
200
+ expect(code).toBe(0);
201
+ // Module install pins the resolved Linux backend, NOT parakeet-mlx.
202
+ expect(r.cmds[0]).toEqual([
203
+ "parachute",
204
+ "install",
205
+ "scribe",
206
+ "--scribe-provider",
207
+ "onnx-asr",
208
+ ]);
209
+ // scribe's own runnable install routine, targeting onnx-asr.
210
+ expect(r.cmds[1]).toEqual(["parachute-scribe", "install-backend", "--provider", "onnx-asr"]);
211
+ // A restart so the running scribe picks up the engine.
212
+ expect(r.cmds[2]).toEqual(["parachute", "restart", "scribe"]);
213
+ } finally {
214
+ h.cleanup();
215
+ }
216
+ });
217
+
218
+ test("macOS local resolves to parakeet-mlx", async () => {
219
+ const h = makeHarness();
220
+ try {
221
+ const r = recordingRunner([0, 0, 0]);
222
+ await walkTranscriptionStep({
223
+ configDir: h.dir,
224
+ log: () => {},
225
+ transcribeMode: "local",
226
+ runCommand: r.run,
227
+ platform: "darwin",
228
+ availableRamMib: 16384,
229
+ });
230
+ expect(r.cmds[1]).toEqual([
231
+ "parachute-scribe",
232
+ "install-backend",
233
+ "--provider",
234
+ "parakeet-mlx",
235
+ ]);
236
+ } finally {
237
+ h.cleanup();
238
+ }
239
+ });
240
+
241
+ test("install-backend FAILS: clears the provisional provider (no dead string) + points at cloud", async () => {
242
+ const h = makeHarness();
243
+ try {
244
+ // module install OK, install-backend FAILS (exit 3). No restart should run.
245
+ const r = recordingRunner([0, 3]);
246
+ const logs: string[] = [];
247
+ const code = await walkTranscriptionStep({
248
+ configDir: h.dir,
249
+ log: (l) => logs.push(l),
250
+ transcribeMode: "local",
251
+ runCommand: r.run,
252
+ platform: "linux",
253
+ availableRamMib: 4096,
254
+ });
255
+ expect(code).toBe(0);
256
+ // Only two commands ran — the failed install-backend, no restart.
257
+ expect(r.cmds.length).toBe(2);
258
+ expect(r.cmds.some((c) => c[0] === "parachute" && c[1] === "restart")).toBe(false);
259
+ // The provisional provider write (by the install one-shot in production)
260
+ // is cleared. We simulate the install having written it by checking the
261
+ // step never leaves a transcribe.provider on its own write path — and the
262
+ // honest cloud steer is logged.
263
+ expect(logs.join("\n")).toContain("install failed");
264
+ expect(logs.join("\n")).toContain("Cloud alternative");
265
+ } finally {
266
+ h.cleanup();
267
+ }
268
+ });
269
+
270
+ test("RAM below floor: REFUSES local, records nothing, steers to cloud one-shot", async () => {
271
+ const h = makeHarness();
272
+ try {
273
+ const r = recordingRunner();
274
+ const logs: string[] = [];
275
+ const code = await walkTranscriptionStep({
276
+ configDir: h.dir,
277
+ log: (l) => logs.push(l),
278
+ transcribeMode: "local",
279
+ runCommand: r.run,
280
+ platform: "linux",
281
+ availableRamMib: 900, // 1 GB droplet
282
+ });
283
+ expect(code).toBe(0);
284
+ // Nothing installed, nothing recorded.
285
+ expect(r.cmds).toEqual([]);
286
+ expect(readCfg(h.dir)).toBeUndefined();
287
+ const joined = logs.join("\n");
288
+ expect(joined).toContain("isn't possible");
289
+ expect(joined).toContain("parachute install scribe --scribe-provider groq");
290
+ } finally {
291
+ h.cleanup();
292
+ }
293
+ });
294
+
295
+ test("unsupported platform: REFUSES local + steers to cloud, no install", async () => {
296
+ const h = makeHarness();
297
+ try {
298
+ const r = recordingRunner();
299
+ const code = await walkTranscriptionStep({
300
+ configDir: h.dir,
301
+ log: () => {},
302
+ transcribeMode: "local",
303
+ runCommand: r.run,
304
+ platform: "win32",
305
+ availableRamMib: 99999,
306
+ });
307
+ expect(code).toBe(0);
308
+ expect(r.cmds).toEqual([]);
309
+ expect(readCfg(h.dir)).toBeUndefined();
310
+ } finally {
311
+ h.cleanup();
312
+ }
313
+ });
314
+
315
+ test("module install fails before the engine: clears provider, no install-backend run", async () => {
316
+ const h = makeHarness();
317
+ try {
318
+ const r = recordingRunner([5]); // module install fails
319
+ const code = await walkTranscriptionStep({
320
+ configDir: h.dir,
321
+ log: () => {},
322
+ transcribeMode: "local",
323
+ runCommand: r.run,
324
+ platform: "linux",
325
+ availableRamMib: 4096,
326
+ });
327
+ expect(code).toBe(0);
328
+ expect(r.cmds.length).toBe(1);
329
+ expect(r.cmds[0]?.[1]).toBe("install");
330
+ } finally {
331
+ h.cleanup();
332
+ }
333
+ });
334
+ });
@@ -293,6 +293,29 @@ describe("parseWizardArgs", () => {
293
293
  expect(r.opts.vaultMode).toBe("import");
294
294
  expect(r.opts.vaultImportRemoteUrl).toBe("https://github.com/me/v.git");
295
295
  });
296
+
297
+ test("parses --transcribe-mode + --transcribe-key + --config-dir", () => {
298
+ const r = parseWizardArgs([
299
+ "--hub-url",
300
+ "http://x",
301
+ "--transcribe-mode",
302
+ "groq",
303
+ "--transcribe-key",
304
+ "gsk_test",
305
+ "--config-dir",
306
+ "/tmp/ph",
307
+ ]);
308
+ expect("error" in r).toBe(false);
309
+ if ("error" in r) throw new Error(r.error);
310
+ expect(r.opts.transcribeMode).toBe("groq");
311
+ expect(r.opts.transcribeApiKey).toBe("gsk_test");
312
+ expect(r.opts.configDir).toBe("/tmp/ph");
313
+ });
314
+
315
+ test("rejects invalid --transcribe-mode", () => {
316
+ const r = parseWizardArgs(["--hub-url", "http://x", "--transcribe-mode", "garbage"]);
317
+ expect("error" in r).toBe(true);
318
+ });
296
319
  });
297
320
 
298
321
  describe("runCliWizard", () => {
@@ -509,4 +532,127 @@ describe("runCliWizard", () => {
509
532
  });
510
533
  expect(code).toBe(1);
511
534
  });
535
+
536
+ test("password validator floor is 12 (matches the server) — an 11-char password is rejected", async () => {
537
+ const { fetchImpl } = makeFakeHub();
538
+ const logs: string[] = [];
539
+ const code = await runCliWizard({
540
+ hubUrl: "http://127.0.0.1:1939",
541
+ log: (l) => logs.push(l),
542
+ fetchImpl,
543
+ sleep: async () => {},
544
+ accountUsername: "admin",
545
+ accountPassword: "elevenchar.", // 11 chars
546
+ vaultMode: "skip",
547
+ exposeMode: "localhost",
548
+ });
549
+ expect(code).toBe(1);
550
+ expect(logs.join("\n")).toContain("at least 12 characters");
551
+ });
552
+
553
+ test("exactly 12 chars passes the validator (no early bounce)", async () => {
554
+ const { state, fetchImpl } = makeFakeHub();
555
+ const code = await runCliWizard({
556
+ hubUrl: "http://127.0.0.1:1939",
557
+ log: () => {},
558
+ fetchImpl,
559
+ sleep: async () => {},
560
+ accountUsername: "admin",
561
+ accountPassword: "twelvecharss", // 12 chars
562
+ vaultMode: "skip",
563
+ exposeMode: "localhost",
564
+ });
565
+ expect(code).toBe(0);
566
+ expect(state.posted.some((p) => p.path === "/admin/setup/account")).toBe(true);
567
+ });
568
+
569
+ test("transcription step runs between vault and expose when configDir is set", async () => {
570
+ const { state, fetchImpl } = makeFakeHub();
571
+ const transcribeCmds: string[][] = [];
572
+ const code = await runCliWizard({
573
+ hubUrl: "http://127.0.0.1:1939",
574
+ log: () => {},
575
+ fetchImpl,
576
+ sleep: async () => {},
577
+ accountUsername: "admin",
578
+ accountPassword: "longpassword",
579
+ vaultMode: "skip",
580
+ exposeMode: "localhost",
581
+ configDir: "/tmp/never-written-none-mode",
582
+ transcribeMode: "none", // none writes nothing + spawns nothing
583
+ transcribeRunCommand: async (cmd) => {
584
+ transcribeCmds.push([...cmd]);
585
+ return 0;
586
+ },
587
+ });
588
+ expect(code).toBe(0);
589
+ // The vault + expose POSTs still happened in order.
590
+ expect(state.posted.map((p) => p.path)).toEqual([
591
+ "/admin/setup/account",
592
+ "/admin/setup/vault",
593
+ "/admin/setup/expose",
594
+ ]);
595
+ // mode=none → no transcription subprocess.
596
+ expect(transcribeCmds).toEqual([]);
597
+ });
598
+
599
+ test("transcription step (cloud) drives the install one-shot via the injected runner", async () => {
600
+ const { fetchImpl } = makeFakeHub();
601
+ const transcribeCmds: string[][] = [];
602
+ const code = await runCliWizard({
603
+ hubUrl: "http://127.0.0.1:1939",
604
+ log: () => {},
605
+ fetchImpl,
606
+ sleep: async () => {},
607
+ accountUsername: "admin",
608
+ accountPassword: "longpassword",
609
+ vaultMode: "skip",
610
+ exposeMode: "localhost",
611
+ configDir: "/tmp/never-written-cloud-mode",
612
+ transcribeMode: "groq",
613
+ transcribeApiKey: "gsk_wired",
614
+ platform: "linux",
615
+ transcribeRunCommand: async (cmd) => {
616
+ transcribeCmds.push([...cmd]);
617
+ return 0;
618
+ },
619
+ });
620
+ expect(code).toBe(0);
621
+ expect(transcribeCmds[0]).toEqual([
622
+ "parachute",
623
+ "install",
624
+ "scribe",
625
+ "--scribe-provider",
626
+ "groq",
627
+ "--scribe-key",
628
+ "gsk_wired",
629
+ ]);
630
+ });
631
+
632
+ test("transcription step is skipped when configDir is absent", async () => {
633
+ const { state, fetchImpl } = makeFakeHub();
634
+ let ran = false;
635
+ const code = await runCliWizard({
636
+ hubUrl: "http://127.0.0.1:1939",
637
+ log: () => {},
638
+ fetchImpl,
639
+ sleep: async () => {},
640
+ accountUsername: "admin",
641
+ accountPassword: "longpassword",
642
+ vaultMode: "skip",
643
+ exposeMode: "localhost",
644
+ // no configDir
645
+ transcribeRunCommand: async () => {
646
+ ran = true;
647
+ return 0;
648
+ },
649
+ });
650
+ expect(code).toBe(0);
651
+ expect(ran).toBe(false);
652
+ expect(state.posted.map((p) => p.path)).toEqual([
653
+ "/admin/setup/account",
654
+ "/admin/setup/vault",
655
+ "/admin/setup/expose",
656
+ ]);
657
+ });
512
658
  });
@@ -55,6 +55,7 @@ import {
55
55
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
56
56
  import { enrichedPath } from "./spawn-path.ts";
57
57
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
+ import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
58
59
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
60
 
60
61
  /**
@@ -609,10 +610,21 @@ async function spawnSupervised(
609
610
  // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
610
611
  // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
611
612
  // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
613
+ // PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
614
+ // 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
615
+ // the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
616
+ // expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
617
+ // its accepted-`iss` check to this set so a token minted under one URL of a
618
+ // multi-URL box validates via another URL of the SAME box. Mirrors
619
+ // buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
620
+ // the set is hub-controlled config/disk state ONLY, never a request Host
621
+ // (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
622
+ const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
612
623
  const childEnv: Record<string, string> = {
613
624
  PATH: enrichedPath(),
614
625
  PORT: String(entry.port),
615
626
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
627
+ ...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
616
628
  ...(deps.spawnEnv ?? {}),
617
629
  };
618
630
  const req: SpawnRequest = {
@@ -172,7 +172,11 @@ export interface InitOpts {
172
172
  * Test seam: shim for the CLI wizard chain (hub#168 Cut 3). Production
173
173
  * lazy-imports `runCliWizard` from `./wizard.ts`. Tests pass a stub.
174
174
  */
175
- runCliWizardImpl?: (opts: { hubUrl: string; log: (l: string) => void }) => Promise<number>;
175
+ runCliWizardImpl?: (opts: {
176
+ hubUrl: string;
177
+ log: (l: string) => void;
178
+ configDir?: string;
179
+ }) => Promise<number>;
176
180
  /**
177
181
  * Skip the "browser or CLI?" wizard-choice prompt (hub#168 Cut 4). Used
178
182
  * by pre-Cut-4 tests that don't expect the new prompt + by the
@@ -485,6 +489,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
485
489
  async function defaultRunCliWizard(opts: {
486
490
  hubUrl: string;
487
491
  log: (l: string) => void;
492
+ configDir?: string;
488
493
  }): Promise<number> {
489
494
  const { runCliWizard } = await import("./wizard.ts");
490
495
  return await runCliWizard(opts);
@@ -895,7 +900,10 @@ export async function init(opts: InitOpts = {}): Promise<number> {
895
900
  // (the loopback-gated GET /admin/setup probe) — the operator never has to
896
901
  // copy the token out of the startup logs.
897
902
  const cliWizardUrl = `http://127.0.0.1:${hubPort}`;
898
- return await runCliWizardImpl({ hubUrl: cliWizardUrl, log });
903
+ // Pass the resolved configDir so the CLI wizard's transcription step can
904
+ // write scribe's config (onboarding-streamline hub PR1). Without it the
905
+ // step is skipped.
906
+ return await runCliWizardImpl({ hubUrl: cliWizardUrl, log, configDir });
899
907
  }
900
908
 
901
909
  // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { join } from "node:path";
20
20
  import { readEnvFileValues } from "../env-file.ts";
21
- import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
21
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
@@ -30,6 +30,7 @@ import {
30
30
  import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
31
31
  import { enrichedPath } from "../spawn-path.ts";
32
32
  import type { Supervisor } from "../supervisor.ts";
33
+ import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
33
34
 
34
35
  export interface BootOpts {
35
36
  /** Path to services.json. */
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
197
198
  PORT: String(entry.port),
198
199
  ...fileEnvSansPort,
199
200
  };
200
- if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
201
+ if (opts.hubOrigin) {
202
+ env[HUB_ORIGIN_ENV] = opts.hubOrigin;
203
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
204
+ // single canonical origin, inject the SET of origins this hub legitimately
205
+ // answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
206
+ // resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
207
+ // this set, so a token minted under one URL of a multi-URL box validates
208
+ // when the resource is reached via another URL of the SAME box. SECURITY:
209
+ // the set is hub-controlled config/disk state only, NEVER a request Host —
210
+ // see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
211
+ // stays for back-compat with older scope-guard.
212
+ const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
213
+ if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
214
+ }
201
215
  if (opts.extraEnv) Object.assign(env, opts.extraEnv);
202
216
 
203
217
  const req: SpawnReqShape = { short, cmd };