@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.
@@ -74,6 +74,11 @@ import {
74
74
  readOperatorTokenFile,
75
75
  } from "./operator-token.ts";
76
76
  import { isHttpsRequest } from "./request-protocol.ts";
77
+ import {
78
+ decideLocalProvider,
79
+ platformLocalProvider,
80
+ readAvailableRamMib,
81
+ } from "./scribe-config.ts";
77
82
  import { SEED_VERSION } from "./service-spec.ts";
78
83
  import { findService, readManifestLenient } from "./services-manifest.ts";
79
84
  import {
@@ -2410,8 +2415,23 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2410
2415
  // Cleanup-without-transcribe is a valid combo: the operator can
2411
2416
  // hit scribe's REST cleanup endpoint directly with their own raw
2412
2417
  // text. We install scribe + write the cleanup block in that case.
2413
- const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2418
+ let scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2414
2419
  const scribeCleanupProvider = String(form.get("scribe_cleanup_provider") ?? "").trim();
2420
+ // RAM/platform gate: if the operator asked for `local` on a box that can't
2421
+ // run a local ASR model (no local backend for the platform, or too little
2422
+ // RAM — the 1 GB droplet would OOM), redirect the choice to a cloud provider
2423
+ // (groq) rather than recording a dead `local` string scribe can never honor.
2424
+ // The reason is logged; the inline UI surfaces it via the scribe op poll.
2425
+ if (scribeProvider === "local") {
2426
+ const decision = decideLocalProvider(process.platform, readAvailableRamMib());
2427
+ if (!decision.ok) {
2428
+ console.warn(
2429
+ `[setup-wizard] local transcription unavailable on this host: ${decision.reason} ` +
2430
+ `Steering to "${decision.steerTo}".`,
2431
+ );
2432
+ scribeProvider = decision.steerTo ?? "groq";
2433
+ }
2434
+ }
2415
2435
  const wantsTranscribe = scribeProvider !== "" && scribeProvider !== "none";
2416
2436
  const wantsCleanup = scribeCleanupProvider !== "" && scribeCleanupProvider !== "none";
2417
2437
  let scribeOpId: string | undefined;
@@ -2590,16 +2610,29 @@ interface WizardScribeConfig {
2590
2610
  transcribe?: { provider: string; apiKey: string };
2591
2611
  /** Set when the operator chose a cleanup provider (anything other than "none"). */
2592
2612
  cleanup?: { provider: string; apiKey: string };
2613
+ /**
2614
+ * Platform override for resolving the `local` choice (test seam). Defaults to
2615
+ * the real host platform. Mac → parakeet-mlx, Linux → onnx-asr.
2616
+ */
2617
+ platform?: NodeJS.Platform;
2593
2618
  }
2594
2619
  function writeScribeConfigForWizard(configDir: string, config: WizardScribeConfig): void {
2595
2620
  const update: Record<string, unknown> = {};
2621
+ const platform = config.platform ?? process.platform;
2596
2622
 
2597
2623
  if (config.transcribe) {
2598
2624
  const { provider, apiKey } = config.transcribe;
2599
- // For `local` (Mac MLX / cross-platform ONNX), just set the
2600
- // provider name no key needed.
2625
+ // For `local`, resolve to the CORRECT platform backend parakeet-mlx on
2626
+ // macOS, onnx-asr on Linux. (Was hardcoded to parakeet-mlx, which silently
2627
+ // fails on every Linux box.) No key needed for local. The caller's
2628
+ // RAM/platform gate is the single place that decides "local isn't possible
2629
+ // here" and should have steered to cloud before reaching this writer — but
2630
+ // if that gate is ever bypassed and the platform has no local backend, we
2631
+ // write "none" (transcription off) rather than a dead provider string, so
2632
+ // this writer can never record something that silently fails.
2601
2633
  if (provider === "local") {
2602
- update.transcribe = { provider: "parakeet-mlx" };
2634
+ const resolved = platformLocalProvider(platform);
2635
+ update.transcribe = { provider: resolved ?? "none" };
2603
2636
  } else {
2604
2637
  // Cloud providers need a key. Empty key → just set provider;
2605
2638
  // the operator can paste the key later via /scribe/admin
@@ -29,7 +29,10 @@
29
29
  import { join } from "node:path";
30
30
  import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
31
  import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
32
- import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
32
+ import { readHubPort } from "./hub-control.ts";
33
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV, serializeHubOrigins } from "./hub-origin.ts";
34
+ import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
35
+ import { buildHubBoundOrigins } from "./origin-check.ts";
33
36
 
34
37
  /**
35
38
  * Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
@@ -91,11 +94,80 @@ function vaultEnvPath(configDir: string): string {
91
94
  return join(configDir, "vault", ".env");
92
95
  }
93
96
 
97
+ /**
98
+ * Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
99
+ * Fly app slug (no slashes — Fly slugs don't contain them). Mirrors the
100
+ * private helper in operator-token.ts / hub-server.ts; kept local so the
101
+ * origin-SET assembly here doesn't reach across modules for a 3-line guard.
102
+ */
103
+ function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
104
+ const app = env.FLY_APP_NAME;
105
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
106
+ return `https://${app}.fly.dev`;
107
+ }
108
+
109
+ /**
110
+ * Assemble the comma-separated `PARACHUTE_HUB_ORIGINS` value the hub injects
111
+ * into supervised resource servers (multi-origin iss-set). The SET is the
112
+ * hub's own legitimate origins — the env-injection sibling of the operator
113
+ * token's `buildKnownIssuersForOperatorToken` and the per-request
114
+ * `buildHubBoundOrigins` call in hub-server.ts. Inputs:
115
+ *
116
+ * - `issuer` — the canonical hub origin the child also receives as the
117
+ * single `PARACHUTE_HUB_ORIGIN` (the seed; always included).
118
+ * - loopback aliases — `http://127.0.0.1:<port>` ∪ `http://localhost:<port>`
119
+ * for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`).
120
+ * - the expose-state public origin — `expose-state.json`'s `hubOrigin`.
121
+ * - the platform/env public origin — `RENDER_EXTERNAL_URL` ∪ the composed
122
+ * Fly default (container deploys where the public origin comes from the
123
+ * platform, not expose-state).
124
+ *
125
+ * SECURITY INVARIANT: every input is hub/operator-controlled config or
126
+ * on-disk state — NEVER an unvalidated request `Host` / `X-Forwarded-Host`.
127
+ * The accepted-`iss` widening this enables is safe only because the resource
128
+ * server verifies the JWKS signature first; this set is the belt-and-
129
+ * suspenders allowlist layered on top.
130
+ *
131
+ * Returns `undefined` when the seed issuer is absent AND nothing else resolves
132
+ * (caller skips the env var so the child keeps single-origin behavior).
133
+ */
134
+ export function buildHubOriginsEnvValue(
135
+ configDir: string,
136
+ issuer: string | undefined,
137
+ env: NodeJS.ProcessEnv = process.env,
138
+ exposeStatePath: string = EXPOSE_STATE_PATH,
139
+ ): string | undefined {
140
+ const loopbackPort = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
141
+ let exposeHubOrigin: string | undefined;
142
+ try {
143
+ exposeHubOrigin = readExposeState(exposeStatePath)?.hubOrigin;
144
+ } catch {
145
+ // A malformed expose-state.json must never block a module spawn — the seed
146
+ // issuer + loopback aliases already cover legitimate access.
147
+ exposeHubOrigin = undefined;
148
+ }
149
+ const platformOrigin = env.RENDER_EXTERNAL_URL ?? flyDefaultOriginFromEnv(env);
150
+ const origins = buildHubBoundOrigins({
151
+ // buildHubBoundOrigins requires `issuer`; pass "" when absent (it's dropped
152
+ // by the URL parse) so the loopback aliases still seed the set.
153
+ issuer: issuer ?? "",
154
+ loopbackPort,
155
+ ...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
156
+ ...(platformOrigin !== undefined ? { platformOrigin } : {}),
157
+ });
158
+ return serializeHubOrigins(origins);
159
+ }
160
+
94
161
  /**
95
162
  * Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
96
- * non-loopback public origin. Idempotent skips the write (and the log) when
97
- * the value is already current so repeated `start`s don't churn the file.
98
- * Returns true iff the file was written this call.
163
+ * non-loopback public origin, AND the `PARACHUTE_HUB_ORIGINS` set (the
164
+ * multi-origin iss-set: origin loopback aliases expose-state platform)
165
+ * so the daemon-boot path validates `iss` against every URL the box answers on
166
+ * (a Caddy-fronted box reached via loopback + sslip.io + a custom domain at
167
+ * once). The set is assembled from hub-controlled inputs only (see
168
+ * `buildHubOriginsEnvValue`'s security invariant). Idempotent — skips the
169
+ * write (and the log) when BOTH values are already current so repeated
170
+ * `start`s don't churn the file. Returns true iff the file was written.
99
171
  */
100
172
  export function persistVaultHubOrigin(
101
173
  configDir: string,
@@ -105,26 +177,47 @@ export function persistVaultHubOrigin(
105
177
  if (isLoopbackOrigin(origin)) return false;
106
178
  const path = vaultEnvPath(configDir);
107
179
  const parsed = parseEnvFile(path);
108
- if (parsed.values[HUB_ORIGIN_ENV] === origin) return false;
109
- writeEnvFile(path, upsertEnvLine(parsed.lines, HUB_ORIGIN_ENV, origin));
110
- log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
180
+ const originsValue = buildHubOriginsEnvValue(configDir, origin);
181
+ const originCurrent = parsed.values[HUB_ORIGIN_ENV] === origin;
182
+ const originsCurrent =
183
+ originsValue === undefined || parsed.values[HUB_ORIGINS_ENV] === originsValue;
184
+ if (originCurrent && originsCurrent) return false;
185
+ let lines = parsed.lines;
186
+ if (!originCurrent) lines = upsertEnvLine(lines, HUB_ORIGIN_ENV, origin);
187
+ if (!originsCurrent && originsValue !== undefined) {
188
+ lines = upsertEnvLine(lines, HUB_ORIGINS_ENV, originsValue);
189
+ }
190
+ writeEnvFile(path, lines);
191
+ if (!originCurrent) {
192
+ log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
193
+ }
194
+ if (!originsCurrent && originsValue !== undefined) {
195
+ log(` persisted ${HUB_ORIGINS_ENV}=${originsValue} to ${path} (multi-origin iss-set)`);
196
+ }
111
197
  return true;
112
198
  }
113
199
 
114
200
  /**
115
- * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` from `vault/.env`. Called
116
- * on `expose … off`: once exposure is torn down, a local-only hub mints tokens
117
- * with a loopback `iss`, so a stale public origin left in `.env` would itself
118
- * cause the mismatch. Removing the line reverts vault to its loopback default
119
- * (`getHubOrigin`), which matches what the local hub now stamps. No-op (returns
120
- * false) when the key isn't present. Returns true iff the file was rewritten.
201
+ * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` (and `PARACHUTE_HUB_ORIGINS`)
202
+ * from `vault/.env`. Called on `expose … off`: once exposure is torn down, a
203
+ * local-only hub mints tokens with a loopback `iss`, so a stale public origin
204
+ * left in `.env` would itself cause the mismatch. Removing the lines reverts
205
+ * vault to its loopback default (`getHubOrigin`), which matches what the local
206
+ * hub now stamps. No-op (returns false) when neither key is present. Returns
207
+ * true iff the file was rewritten.
121
208
  */
122
209
  export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
123
210
  const path = vaultEnvPath(configDir);
124
211
  const parsed = parseEnvFile(path);
125
- if (parsed.values[HUB_ORIGIN_ENV] === undefined) return false;
126
- writeEnvFile(path, removeEnvLine(parsed.lines, HUB_ORIGIN_ENV));
127
- log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
212
+ const hadOrigin = parsed.values[HUB_ORIGIN_ENV] !== undefined;
213
+ const hadOrigins = parsed.values[HUB_ORIGINS_ENV] !== undefined;
214
+ if (!hadOrigin && !hadOrigins) return false;
215
+ let lines = parsed.lines;
216
+ if (hadOrigin) lines = removeEnvLine(lines, HUB_ORIGIN_ENV);
217
+ if (hadOrigins) lines = removeEnvLine(lines, HUB_ORIGINS_ENV);
218
+ writeEnvFile(path, lines);
219
+ if (hadOrigin) log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
220
+ if (hadOrigins) log(` cleared ${HUB_ORIGINS_ENV} from ${path} (exposure torn down)`);
128
221
  return true;
129
222
  }
130
223