@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.
- package/package.json +1 -1
- package/src/__tests__/hub-origins-env-set.test.ts +199 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/api-modules-ops.ts +12 -0
- package/src/commands/init.ts +10 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/hub-origin.ts +64 -0
- package/src/jwt-sign.ts +14 -3
- package/src/scribe-config.ts +145 -0
- package/src/setup-wizard.ts +37 -4
- package/src/vault-hub-origin-env.ts +109 -16
package/package.json
CHANGED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25) — hub side.
|
|
3
|
+
*
|
|
4
|
+
* The hub publishes the SET of origins it legitimately answers on to its
|
|
5
|
+
* supervised resource servers via `PARACHUTE_HUB_ORIGINS` (comma-separated),
|
|
6
|
+
* alongside the single canonical `PARACHUTE_HUB_ORIGIN`. A resource server on
|
|
7
|
+
* scope-guard ≥0.5.0 widens its accepted-`iss` check to this set so a token
|
|
8
|
+
* minted under one URL of a multi-URL box validates via another URL of the
|
|
9
|
+
* SAME box.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin two things:
|
|
12
|
+
* 1. The serialize/parse round-trip + the assembly from hub-controlled
|
|
13
|
+
* inputs (issuer ∪ loopback aliases ∪ expose-state ∪ platform).
|
|
14
|
+
* 2. The SECURITY INVARIANT: the set is built ONLY from operator/hub config
|
|
15
|
+
* and on-disk state — never from an unvalidated request `Host` /
|
|
16
|
+
* `X-Forwarded-Host`. We feed an attacker-controlled "Host" through every
|
|
17
|
+
* input channel a request could plausibly reach and assert it never lands
|
|
18
|
+
* in the published set.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
|
|
25
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
26
|
+
|
|
27
|
+
describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
|
|
28
|
+
test("serialize dedupes, drops empties, strips trailing slashes", () => {
|
|
29
|
+
const v = serializeHubOrigins([
|
|
30
|
+
"https://a.example/",
|
|
31
|
+
"https://a.example",
|
|
32
|
+
"",
|
|
33
|
+
"https://b.example",
|
|
34
|
+
]);
|
|
35
|
+
expect(v).toBe("https://a.example,https://b.example");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("serialize returns undefined when nothing survives", () => {
|
|
39
|
+
expect(serializeHubOrigins([])).toBeUndefined();
|
|
40
|
+
expect(serializeHubOrigins(["", " "])).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parse is the inverse — tolerant of whitespace + trailing slashes + empties", () => {
|
|
44
|
+
expect(parseHubOrigins("https://a.example, https://b.example/ ,,")).toEqual([
|
|
45
|
+
"https://a.example",
|
|
46
|
+
"https://b.example",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("parse returns [] for absent/empty/garbage", () => {
|
|
51
|
+
expect(parseHubOrigins(undefined)).toEqual([]);
|
|
52
|
+
expect(parseHubOrigins("")).toEqual([]);
|
|
53
|
+
expect(parseHubOrigins(" , , ")).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("round-trips a real set", () => {
|
|
57
|
+
const origins = ["https://example.com", "http://127.0.0.1:1939", "http://localhost:1939"];
|
|
58
|
+
const wire = serializeHubOrigins(origins)!;
|
|
59
|
+
expect(parseHubOrigins(wire)).toEqual(origins);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set", () => {
|
|
64
|
+
let dir: string;
|
|
65
|
+
const EXPOSE = () => join(dir, "expose-state.json");
|
|
66
|
+
|
|
67
|
+
/** Write a schema-valid expose-state.json carrying the given hubOrigin. */
|
|
68
|
+
function writeExposeState(hubOrigin: string): void {
|
|
69
|
+
writeFileSync(
|
|
70
|
+
EXPOSE(),
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
version: 1,
|
|
73
|
+
layer: "public",
|
|
74
|
+
mode: "path",
|
|
75
|
+
canonicalFqdn: new URL(hubOrigin).host,
|
|
76
|
+
port: 1939,
|
|
77
|
+
funnel: true,
|
|
78
|
+
entries: [],
|
|
79
|
+
hubOrigin,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-set-"));
|
|
86
|
+
// No hub.port file in this fresh configDir → readHubPort falls back to
|
|
87
|
+
// HUB_UNIT_DEFAULT_PORT (1939), the deterministic value these cases assert.
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
90
|
+
|
|
91
|
+
test("issuer ∪ loopback aliases when no expose / platform origin", () => {
|
|
92
|
+
const v = buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE());
|
|
93
|
+
const set = parseHubOrigins(v);
|
|
94
|
+
expect(set).toContain("https://example.com");
|
|
95
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
96
|
+
expect(set).toContain("http://localhost:1939");
|
|
97
|
+
expect(set).toHaveLength(3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("loopback aliases are ALWAYS present (the always-present invariant)", () => {
|
|
101
|
+
// Even with a public issuer, loopback must be in the set so the co-located
|
|
102
|
+
// CLI / loopback-proxied request path validates.
|
|
103
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
104
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
105
|
+
expect(set).toContain("http://localhost:1939");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("folds in the expose-state public origin", () => {
|
|
109
|
+
writeExposeState("https://box.sslip.io");
|
|
110
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
111
|
+
expect(set).toContain("https://example.com");
|
|
112
|
+
expect(set).toContain("https://box.sslip.io");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("folds in the platform origin (RENDER_EXTERNAL_URL)", () => {
|
|
116
|
+
const set = parseHubOrigins(
|
|
117
|
+
buildHubOriginsEnvValue(
|
|
118
|
+
dir,
|
|
119
|
+
"https://example.com",
|
|
120
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
121
|
+
EXPOSE(),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
expect(set).toContain("https://app.onrender.com");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("folds in the composed Fly default origin", () => {
|
|
128
|
+
const set = parseHubOrigins(
|
|
129
|
+
buildHubOriginsEnvValue(dir, "https://example.com", { FLY_APP_NAME: "myapp" }, EXPOSE()),
|
|
130
|
+
);
|
|
131
|
+
expect(set).toContain("https://myapp.fly.dev");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("absent issuer → loopback-only set (still useful, never empty on a normal box)", () => {
|
|
135
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, undefined, {}, EXPOSE()));
|
|
136
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
137
|
+
expect(set).toContain("http://localhost:1939");
|
|
138
|
+
// The empty issuer "" is dropped by buildHubBoundOrigins' URL parse.
|
|
139
|
+
expect(set).not.toContain("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a malformed expose-state.json never throws — falls back to issuer + loopback", () => {
|
|
143
|
+
writeFileSync(EXPOSE(), "{ not valid json");
|
|
144
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
145
|
+
expect(set).toContain("https://example.com");
|
|
146
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("SECURITY INVARIANT — request Host never enters the set", () => {
|
|
150
|
+
const ATTACKER = "https://attacker.evil";
|
|
151
|
+
|
|
152
|
+
test("an attacker Host smuggled via expose-state IS honored ONLY because it's operator-written on-disk state — but a Host passed nowhere never appears", () => {
|
|
153
|
+
// There is NO request input to buildHubOriginsEnvValue at all — it takes
|
|
154
|
+
// configDir + issuer + env + expose-state-path. None of those is a
|
|
155
|
+
// request header. We assert the function's surface offers no channel for
|
|
156
|
+
// a request Host: feeding the attacker value to the only inputs an
|
|
157
|
+
// attacker might influence (a stray env var, a header-shaped string)
|
|
158
|
+
// never reaches the set unless it's a legitimate operator-config var.
|
|
159
|
+
const set = parseHubOrigins(
|
|
160
|
+
buildHubOriginsEnvValue(
|
|
161
|
+
dir,
|
|
162
|
+
"https://example.com",
|
|
163
|
+
{
|
|
164
|
+
// Header-shaped env vars an attacker might hope are read — none are
|
|
165
|
+
// consulted by the assembler (only RENDER_EXTERNAL_URL / FLY_APP_NAME).
|
|
166
|
+
HTTP_HOST: ATTACKER,
|
|
167
|
+
HTTP_X_FORWARDED_HOST: ATTACKER,
|
|
168
|
+
X_FORWARDED_HOST: ATTACKER,
|
|
169
|
+
HOST: ATTACKER,
|
|
170
|
+
} as NodeJS.ProcessEnv,
|
|
171
|
+
EXPOSE(),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
expect(set).not.toContain(ATTACKER);
|
|
175
|
+
expect(set.some((o) => o.includes("attacker"))).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the set is exactly issuer ∪ loopback ∪ expose ∪ platform — no other source", () => {
|
|
179
|
+
writeExposeState("https://box.sslip.io");
|
|
180
|
+
const set = parseHubOrigins(
|
|
181
|
+
buildHubOriginsEnvValue(
|
|
182
|
+
dir,
|
|
183
|
+
"https://example.com",
|
|
184
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
185
|
+
EXPOSE(),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
// Every member is one of the four sanctioned sources; nothing else.
|
|
189
|
+
const sanctioned = new Set([
|
|
190
|
+
"https://example.com",
|
|
191
|
+
"http://127.0.0.1:1939",
|
|
192
|
+
"http://localhost:1939",
|
|
193
|
+
"https://box.sslip.io",
|
|
194
|
+
"https://app.onrender.com",
|
|
195
|
+
]);
|
|
196
|
+
for (const o of set) expect(sanctioned.has(o)).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -3,10 +3,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
MIN_RAM_MIB,
|
|
6
7
|
SCRIBE_DEFAULT_PROVIDER,
|
|
7
8
|
SCRIBE_PROVIDERS,
|
|
8
9
|
apiKeyEnvFor,
|
|
10
|
+
clearScribeProvider,
|
|
11
|
+
decideLocalProvider,
|
|
9
12
|
isKnownScribeProvider,
|
|
13
|
+
platformLocalProvider,
|
|
14
|
+
readAvailableRamMib,
|
|
10
15
|
readScribeProviderState,
|
|
11
16
|
scribeConfigPath,
|
|
12
17
|
scribeEnvPath,
|
|
@@ -41,6 +46,118 @@ describe("provider catalog", () => {
|
|
|
41
46
|
});
|
|
42
47
|
});
|
|
43
48
|
|
|
49
|
+
describe("platformLocalProvider — the Linux 'local' trap fix", () => {
|
|
50
|
+
test("macOS → parakeet-mlx", () => {
|
|
51
|
+
expect(platformLocalProvider("darwin")).toBe("parakeet-mlx");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Linux → onnx-asr (NOT the macOS-only parakeet-mlx)", () => {
|
|
55
|
+
expect(platformLocalProvider("linux")).toBe("onnx-asr");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("unsupported platform → null (steer to cloud)", () => {
|
|
59
|
+
expect(platformLocalProvider("win32")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("readAvailableRamMib", () => {
|
|
64
|
+
test("non-Linux returns a positive number (totalmem fallback) or null", () => {
|
|
65
|
+
const ram = readAvailableRamMib("darwin");
|
|
66
|
+
// On any real CI/dev box totalmem is well-defined + positive.
|
|
67
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Linux path reads /proc/meminfo (or null when unreadable)", () => {
|
|
71
|
+
const ram = readAvailableRamMib("linux");
|
|
72
|
+
// On macOS CI there's no /proc/meminfo → null; on Linux CI a positive MiB.
|
|
73
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("decideLocalProvider — the RAM/platform gate", () => {
|
|
78
|
+
test("Linux with ample RAM → ok, onnx-asr", () => {
|
|
79
|
+
const d = decideLocalProvider("linux", 4096);
|
|
80
|
+
expect(d.ok).toBe(true);
|
|
81
|
+
expect(d.provider).toBe("onnx-asr");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("macOS with ample RAM → ok, parakeet-mlx", () => {
|
|
85
|
+
const d = decideLocalProvider("darwin", 16384);
|
|
86
|
+
expect(d.ok).toBe(true);
|
|
87
|
+
expect(d.provider).toBe("parakeet-mlx");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("below the RAM floor → refused, steers to groq, carries a reason", () => {
|
|
91
|
+
const d = decideLocalProvider("linux", MIN_RAM_MIB - 1);
|
|
92
|
+
expect(d.ok).toBe(false);
|
|
93
|
+
expect(d.steerTo).toBe("groq");
|
|
94
|
+
expect(d.reason).toContain(String(MIN_RAM_MIB));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("exactly at the floor is OK (>= floor)", () => {
|
|
98
|
+
expect(decideLocalProvider("linux", MIN_RAM_MIB).ok).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("unknown RAM (null) does not refuse on a supported platform", () => {
|
|
102
|
+
expect(decideLocalProvider("linux", null).ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unsupported platform → refused regardless of RAM, steers to groq", () => {
|
|
106
|
+
const d = decideLocalProvider("win32", 99999);
|
|
107
|
+
expect(d.ok).toBe(false);
|
|
108
|
+
expect(d.steerTo).toBe("groq");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("clearScribeProvider", () => {
|
|
113
|
+
test("removes transcribe.provider, preserving other keys", () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
117
|
+
writeFileSync(
|
|
118
|
+
scribeConfigPath(h.dir),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
transcribe: { provider: "onnx-asr", language: "en" },
|
|
121
|
+
auth: { required_token: "keep" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
clearScribeProvider(h.dir);
|
|
125
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
126
|
+
expect(parsed.transcribe).toEqual({ language: "en" });
|
|
127
|
+
expect(parsed.auth).toEqual({ required_token: "keep" });
|
|
128
|
+
} finally {
|
|
129
|
+
h.cleanup();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("drops the transcribe block entirely when provider was its only key", () => {
|
|
134
|
+
const h = makeHarness();
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
137
|
+
writeFileSync(
|
|
138
|
+
scribeConfigPath(h.dir),
|
|
139
|
+
JSON.stringify({ transcribe: { provider: "onnx-asr" }, auth: { required_token: "x" } }),
|
|
140
|
+
);
|
|
141
|
+
clearScribeProvider(h.dir);
|
|
142
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
143
|
+
expect(parsed.transcribe).toBeUndefined();
|
|
144
|
+
expect(parsed.auth).toEqual({ required_token: "x" });
|
|
145
|
+
} finally {
|
|
146
|
+
h.cleanup();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no-op when the file is absent", () => {
|
|
151
|
+
const h = makeHarness();
|
|
152
|
+
try {
|
|
153
|
+
// No file written.
|
|
154
|
+
expect(() => clearScribeProvider(h.dir)).not.toThrow();
|
|
155
|
+
} finally {
|
|
156
|
+
h.cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
44
161
|
describe("readScribeProviderState", () => {
|
|
45
162
|
test("missing file: configExists false, no provider", () => {
|
|
46
163
|
const h = makeHarness();
|
|
@@ -125,6 +125,51 @@ describe("bootSupervisedModules", () => {
|
|
|
125
125
|
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
test("forwards PARACHUTE_HUB_ORIGINS (the multi-origin iss-set) alongside the single origin", async () => {
|
|
129
|
+
// Multi-origin iss-set (onboarding-streamline 2026-06-25): a resource
|
|
130
|
+
// server on scope-guard ≥0.5.0 widens its accepted-`iss` check to this set
|
|
131
|
+
// so a token minted under one URL of a multi-URL box validates via another.
|
|
132
|
+
// The set always carries the issuer + loopback aliases (the deterministic
|
|
133
|
+
// members); expose-state / platform members vary by box.
|
|
134
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
135
|
+
const recorder = makeRecorder();
|
|
136
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
137
|
+
|
|
138
|
+
await bootSupervisedModules(sup, {
|
|
139
|
+
manifestPath: h.manifestPath,
|
|
140
|
+
configDir: h.dir,
|
|
141
|
+
hubOrigin: "https://hub.example",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const origins = recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS;
|
|
145
|
+
expect(origins).toBeDefined();
|
|
146
|
+
const set = (origins ?? "").split(",");
|
|
147
|
+
expect(set).toContain("https://hub.example");
|
|
148
|
+
// SECURITY: the set is hub-controlled (issuer + loopback + expose +
|
|
149
|
+
// platform) — never a request Host. Loopback aliases are always present.
|
|
150
|
+
expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
|
|
151
|
+
expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
|
|
152
|
+
// And the single canonical origin is still written for back-compat.
|
|
153
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("no PARACHUTE_HUB_ORIGINS when hubOrigin is unset (no widening, single-origin only)", async () => {
|
|
157
|
+
// Back-compat: a boot with no hubOrigin neither sets PARACHUTE_HUB_ORIGIN
|
|
158
|
+
// nor the set — the child keeps its own loopback default + scope-guard's
|
|
159
|
+
// single-origin behavior.
|
|
160
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
161
|
+
const recorder = makeRecorder();
|
|
162
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
163
|
+
|
|
164
|
+
await bootSupervisedModules(sup, {
|
|
165
|
+
manifestPath: h.manifestPath,
|
|
166
|
+
configDir: h.dir,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
170
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
128
173
|
test("sets PORT in child env from services.json entry (hub#357)", async () => {
|
|
129
174
|
// Container deploys (Render etc.) set PORT in hub's process.env via
|
|
130
175
|
// Dockerfile / platform injection. The supervisor's defaultSpawnFn
|
|
@@ -1825,6 +1825,24 @@ describe("handleSetupVaultPost", () => {
|
|
|
1825
1825
|
expect(cfg?.cleanup).toEqual({ provider: "anthropic", default: true });
|
|
1826
1826
|
expect(cfg?.cleanupProviders).toEqual({ anthropic: { apiKey: "sk-ant-cleanup-key" } });
|
|
1827
1827
|
});
|
|
1828
|
+
|
|
1829
|
+
test("scribe transcribe=local resolves to the host platform's backend (the Linux trap fix)", async () => {
|
|
1830
|
+
// The bug: `local` mapped UNCONDITIONALLY to parakeet-mlx (macOS-only),
|
|
1831
|
+
// silently broken on every Linux box. After the fix it resolves to the
|
|
1832
|
+
// platform backend. We assert against the actual host so it's correct on
|
|
1833
|
+
// both Mac (parakeet-mlx) and Linux CI (onnx-asr). The RAM gate only kicks
|
|
1834
|
+
// in below 2 GB — CI/dev boxes clear it, so the choice stays `local`.
|
|
1835
|
+
const { platformLocalProvider } = await import("../scribe-config.ts");
|
|
1836
|
+
const expected = platformLocalProvider(process.platform);
|
|
1837
|
+
const { response } = await postVaultWithFields(h, { scribe_provider: "local" });
|
|
1838
|
+
expect(response.status).toBe(303);
|
|
1839
|
+
const cfg = readScribeConfig(h.dir);
|
|
1840
|
+
if (expected !== null) {
|
|
1841
|
+
// On a supported platform with enough RAM, `local` resolves to the
|
|
1842
|
+
// platform backend — NOT a hardcoded parakeet-mlx on Linux.
|
|
1843
|
+
expect(cfg?.transcribe).toEqual({ provider: expected });
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1828
1846
|
});
|
|
1829
1847
|
|
|
1830
1848
|
// --- end-to-end through hubFetch -----------------------------------------
|
|
@@ -76,13 +76,29 @@ describe("persistVaultHubOrigin", () => {
|
|
|
76
76
|
expect(existsSync(vaultEnv())).toBe(false);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
test("is idempotent — no rewrite when the
|
|
79
|
+
test("is idempotent — no rewrite when both the origin and the set are current", () => {
|
|
80
80
|
const log: string[] = [];
|
|
81
81
|
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
|
|
82
|
+
// First call wrote BOTH the single origin and the multi-origin set.
|
|
83
|
+
expect(
|
|
84
|
+
log.some((l) => /persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/.test(l)),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
expect(log.some((l) => /persisted PARACHUTE_HUB_ORIGINS=/.test(l))).toBe(true);
|
|
87
|
+
const writesAfterFirst = log.length;
|
|
88
|
+
// Second call is a no-op: both values already current → false, no new logs.
|
|
82
89
|
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
expect(log).toHaveLength(writesAfterFirst);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("writes PARACHUTE_HUB_ORIGINS (multi-origin iss-set) alongside the single origin", () => {
|
|
94
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", () => {})).toBe(true);
|
|
95
|
+
const values = readEnvFileValues(vaultEnv());
|
|
96
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example.com");
|
|
97
|
+
// The set carries the issuer + loopback aliases (always present).
|
|
98
|
+
const set = (values.PARACHUTE_HUB_ORIGINS ?? "").split(",");
|
|
99
|
+
expect(set).toContain("https://hub.example.com");
|
|
100
|
+
expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
|
|
101
|
+
expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
|
|
86
102
|
});
|
|
87
103
|
|
|
88
104
|
test("updates a stale origin in-place and preserves sibling keys", () => {
|
|
@@ -113,6 +129,24 @@ describe("clearVaultHubOrigin", () => {
|
|
|
113
129
|
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
114
130
|
});
|
|
115
131
|
|
|
132
|
+
test("removes BOTH the single origin and the multi-origin set, leaving siblings", () => {
|
|
133
|
+
writeFileSync(
|
|
134
|
+
mkVaultDir(),
|
|
135
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\nPARACHUTE_HUB_ORIGINS=https://hub.example.com,http://127.0.0.1:1939\n",
|
|
136
|
+
);
|
|
137
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
|
|
138
|
+
const values = readEnvFileValues(vaultEnv());
|
|
139
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
140
|
+
expect(values.PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
141
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("clears a lone PARACHUTE_HUB_ORIGINS even if the single origin is absent", () => {
|
|
145
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGINS=https://hub.example.com\n");
|
|
146
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
|
|
147
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
116
150
|
test("no-op when no origin is present", () => {
|
|
117
151
|
writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
|
|
118
152
|
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|