@openparachute/hub 0.7.3-rc.1 → 0.7.3-rc.10

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.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-handlers.test.ts +413 -5
  13. package/src/__tests__/public-signup.test.ts +619 -0
  14. package/src/__tests__/rate-limit.test.ts +86 -0
  15. package/src/__tests__/scribe-config.test.ts +117 -0
  16. package/src/__tests__/serve-boot.test.ts +45 -0
  17. package/src/__tests__/serve.test.ts +67 -1
  18. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  19. package/src/__tests__/setup-wizard.test.ts +18 -0
  20. package/src/__tests__/setup.test.ts +129 -15
  21. package/src/__tests__/two-factor-flow.test.ts +94 -0
  22. package/src/__tests__/users.test.ts +33 -0
  23. package/src/__tests__/vault-caps.test.ts +89 -0
  24. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  25. package/src/__tests__/wizard-transcription.test.ts +334 -0
  26. package/src/__tests__/wizard.test.ts +146 -0
  27. package/src/account-setup.ts +118 -32
  28. package/src/admin-agent-grants.ts +51 -3
  29. package/src/admin-handlers.ts +53 -16
  30. package/src/admin-login-ui.ts +30 -4
  31. package/src/admin-vaults.ts +9 -1
  32. package/src/api-invites.ts +163 -3
  33. package/src/api-modules-ops.ts +12 -0
  34. package/src/api-modules.ts +47 -13
  35. package/src/api-users.ts +3 -0
  36. package/src/api-vault-caps.ts +206 -0
  37. package/src/cli.ts +35 -1
  38. package/src/commands/hub.ts +173 -0
  39. package/src/commands/init.ts +73 -2
  40. package/src/commands/serve-boot.ts +16 -2
  41. package/src/commands/serve.ts +39 -3
  42. package/src/commands/setup.ts +31 -6
  43. package/src/commands/wizard-transcription.ts +296 -0
  44. package/src/commands/wizard.ts +73 -3
  45. package/src/help.ts +8 -0
  46. package/src/hub-db.ts +108 -1
  47. package/src/hub-origin.ts +64 -0
  48. package/src/hub-server.ts +27 -0
  49. package/src/invites.ts +155 -31
  50. package/src/jwt-sign.ts +72 -3
  51. package/src/module-manifest.ts +23 -9
  52. package/src/oauth-flows-store.ts +12 -0
  53. package/src/oauth-handlers.ts +145 -20
  54. package/src/rate-limit.ts +111 -20
  55. package/src/scribe-config.ts +145 -0
  56. package/src/service-spec.ts +31 -12
  57. package/src/setup-wizard.ts +37 -4
  58. package/src/users.ts +62 -3
  59. package/src/vault-caps.ts +109 -0
  60. package/src/vault-hub-origin-env.ts +109 -16
  61. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  62. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  63. package/web/ui/dist/index.html +2 -2
  64. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import {
3
+ AUTH_IP_CEILING_MAX_ATTEMPTS,
3
4
  CHANGE_PASSWORD_MAX_ATTEMPTS,
4
5
  CHANGE_PASSWORD_WINDOW_MS,
5
6
  MAX_ATTEMPTS,
@@ -7,9 +8,12 @@ import {
7
8
  UNKNOWN_IP_SENTINEL,
8
9
  WINDOW_MS,
9
10
  __resetForTests,
11
+ authIpCeilingRateLimiter,
10
12
  changePasswordRateLimiter,
11
13
  checkAndRecord,
12
14
  clientIpFromRequest,
15
+ compositeKey,
16
+ loginRateLimiter,
13
17
  } from "../rate-limit.ts";
14
18
 
15
19
  afterEach(() => {
@@ -302,3 +306,85 @@ describe("changePasswordRateLimiter — tighter floor for /account/change-passwo
302
306
  expect(changePasswordRateLimiter.checkAndRecord("user-a", now).allowed).toBe(true);
303
307
  });
304
308
  });
309
+
310
+ // The shared-egress-IP fix: the per-account FLOOR is now keyed by
311
+ // `compositeKey(ip, identity)` so a room of users behind ONE NAT'd /
312
+ // Cloudflare egress IP doesn't pool into one 5-slot bucket. A coarse per-IP
313
+ // CEILING (60/15min) backstops username-rotation across the floors.
314
+ describe("compositeKey + shared-egress-IP login floor", () => {
315
+ test("normalizes identity: trims + lowercases so casing/whitespace share a bucket", () => {
316
+ expect(compositeKey("1.2.3.4", "Alice")).toBe("1.2.3.4|alice");
317
+ expect(compositeKey("1.2.3.4", " ALICE ")).toBe("1.2.3.4|alice");
318
+ // Case-flip evasion is closed: 'Alice' and 'alice' resolve to one key.
319
+ expect(compositeKey("1.2.3.4", "Alice")).toBe(compositeKey("1.2.3.4", "alice"));
320
+ });
321
+
322
+ // (a) REGRESSION: two distinct usernames from the SAME ip each get a full
323
+ // independent 5/15min floor (the shared-wifi bug). Before the fix both would
324
+ // have pooled into one per-IP bucket and the second user's 1st attempt would
325
+ // have been the 6th overall → 429.
326
+ test("(a) two usernames from the same IP each get an independent 5/15min floor", () => {
327
+ const ip = "203.0.113.7";
328
+ const now = new Date("2026-06-25T12:00:00Z");
329
+ // Alice exhausts her floor.
330
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
331
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
332
+ }
333
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(false);
334
+ // Bob (same IP, different username) still has a fresh full floor.
335
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
336
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(true);
337
+ }
338
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(false);
339
+ });
340
+
341
+ // (b) a single (ip,username) still denies on the 6th attempt.
342
+ test("(b) a single (ip,username) still denies on the 6th attempt", () => {
343
+ const ip = "203.0.113.7";
344
+ const now = new Date("2026-06-25T12:00:00Z");
345
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
346
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
347
+ }
348
+ const denied = loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now);
349
+ expect(denied.allowed).toBe(false);
350
+ expect(denied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
351
+ });
352
+
353
+ // (c) the per-IP ceiling denies on the 61st attempt from one IP even across
354
+ // rotated usernames (each username's own floor never trips because each only
355
+ // sees one attempt, but the coarse ceiling caps total per-IP volume).
356
+ test("(c) per-IP ceiling denies the 61st attempt across rotated usernames", () => {
357
+ const ip = "203.0.113.7";
358
+ const now = new Date("2026-06-25T12:00:00Z");
359
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
360
+ // Rotate a fresh username every attempt so no per-account floor ever fills.
361
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, `u${i}`), now).allowed).toBe(true);
362
+ expect(authIpCeilingRateLimiter.checkAndRecord(ip, now).allowed).toBe(true);
363
+ }
364
+ // 61st attempt: a brand-new username (floor is fresh) but the ceiling is full.
365
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "u60"), now).allowed).toBe(true);
366
+ const ceilingDenied = authIpCeilingRateLimiter.checkAndRecord(ip, now);
367
+ expect(ceilingDenied.allowed).toBe(false);
368
+ expect(ceilingDenied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
369
+ });
370
+
371
+ test("the ceiling is per-IP: a different IP is unaffected by another's full ceiling", () => {
372
+ const now = new Date("2026-06-25T12:00:00Z");
373
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
374
+ authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
375
+ }
376
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
377
+ expect(authIpCeilingRateLimiter.checkAndRecord("198.51.100.99", now).allowed).toBe(true);
378
+ });
379
+
380
+ test("ceiling matches the signup precedent (60) and `__resetForTests` clears it", () => {
381
+ expect(AUTH_IP_CEILING_MAX_ATTEMPTS).toBe(60);
382
+ const now = new Date("2026-06-25T12:00:00Z");
383
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
384
+ authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
385
+ }
386
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
387
+ __resetForTests();
388
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(true);
389
+ });
390
+ });
@@ -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
@@ -21,7 +21,9 @@ describe("hubServeOptions — the production listener wires the WS bridge", () =
21
21
  // upgrades (the channel in-page terminal) 500'd through the hub
22
22
  // ("set the websocket object in Bun.serve({})"). The listener MUST declare a
23
23
  // websocket handler or `server.upgrade()` throws.
24
- const fakeFetch = (() => new Response("ok")) as unknown as Parameters<typeof hubServeOptions>[0]["fetch"];
24
+ const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
25
+ typeof hubServeOptions
26
+ >[0]["fetch"];
25
27
 
26
28
  test("declares a websocket handler set (open/message/close)", () => {
27
29
  const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
@@ -404,6 +406,70 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
404
406
  test("FLY_APP_NAME empty string → no fallback", () => {
405
407
  expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
406
408
  });
409
+
410
+ // onboarding-streamline 2026-06-25 — the Caddy-direct zero-SSH boot-issuer
411
+ // fix. The operator-set `hub_settings.hub_origin` (tier-1 in resolveIssuer)
412
+ // MUST also drive the boot-time issuer, else a box whose ONLY canonical-
413
+ // origin source is the DB row boots without an issuer and injects only
414
+ // loopback into supervised children's PARACHUTE_HUB_ORIGINS.
415
+ describe("dbHubOrigin tier (DB hub_settings.hub_origin)", () => {
416
+ test("dbHubOrigin wins over env/platform/expose (mirrors resolveIssuer tier-1)", () => {
417
+ const got = resolveStartupIssuer(
418
+ { dbHubOrigin: "https://box.sslip.io" },
419
+ {
420
+ PARACHUTE_HUB_ORIGIN: "https://env.example",
421
+ RENDER_EXTERNAL_URL: "https://render.example.onrender.com",
422
+ },
423
+ () => "https://exposed.example",
424
+ );
425
+ expect(got).toBe("https://box.sslip.io");
426
+ });
427
+
428
+ test("dbHubOrigin even wins over explicit opts.issuer (DB is the operator's deliberate canonical)", () => {
429
+ const got = resolveStartupIssuer(
430
+ { issuer: "https://flag.example", dbHubOrigin: "https://box.sslip.io" },
431
+ {},
432
+ noExpose,
433
+ );
434
+ expect(got).toBe("https://box.sslip.io");
435
+ });
436
+
437
+ test("dbHubOrigin trailing slash is stripped", () => {
438
+ expect(resolveStartupIssuer({ dbHubOrigin: "https://box.sslip.io/" }, {}, noExpose)).toBe(
439
+ "https://box.sslip.io",
440
+ );
441
+ });
442
+
443
+ test("a loopback dbHubOrigin is rejected (sanitized) → falls through to next tier", () => {
444
+ // A stray loopback value in the DB must NOT pin the issuer to a non-
445
+ // public origin; fall through to env so the box keeps a usable issuer.
446
+ const got = resolveStartupIssuer(
447
+ { dbHubOrigin: "http://127.0.0.1:1939" },
448
+ { PARACHUTE_HUB_ORIGIN: "https://env.example" },
449
+ noExpose,
450
+ );
451
+ expect(got).toBe("https://env.example");
452
+ });
453
+
454
+ test("a non-http(s) dbHubOrigin is rejected → falls through", () => {
455
+ const got = resolveStartupIssuer(
456
+ { dbHubOrigin: "ftp://box.example" },
457
+ {},
458
+ () => "https://exposed.example",
459
+ );
460
+ expect(got).toBe("https://exposed.example");
461
+ });
462
+
463
+ test("undefined dbHubOrigin is a no-op (unchanged precedence)", () => {
464
+ expect(
465
+ resolveStartupIssuer(
466
+ { dbHubOrigin: undefined },
467
+ { PARACHUTE_HUB_ORIGIN: "https://e.x" },
468
+ noExpose,
469
+ ),
470
+ ).toBe("https://e.x");
471
+ });
472
+ });
407
473
  });
408
474
 
409
475
  describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
@@ -53,14 +53,34 @@ describe("focusForShort", () => {
53
53
  expect(focusForShort("vault")).toBe("core");
54
54
  expect(focusForShort("scribe")).toBe("core");
55
55
  expect(focusForShort("surface")).toBe("core");
56
+ // agent stays a legit experimental preview — still offered on a fresh install.
56
57
  expect(focusForShort("agent")).toBe("experimental");
57
- expect(focusForShort("runner")).toBe("experimental");
58
- expect(focusForShort("notes")).toBe("experimental");
58
+ // notes (notes-daemon, deprecated 2026-05-22) + runner (per Aaron
59
+ // 2026-06-25, not for new installs) are `deprecated`: still resolvable +
60
+ // shown-if-installed, but NOT offered on a fresh setup.
61
+ expect(focusForShort("runner")).toBe("deprecated");
62
+ expect(focusForShort("notes")).toBe("deprecated");
59
63
  });
60
64
 
61
65
  test("unlisted shorts default to experimental", () => {
62
66
  expect(focusForShort("some-third-party-module")).toBe("experimental");
63
67
  });
68
+
69
+ test("a declared deprecated focus is honored over the default map", () => {
70
+ // A module can self-declare `deprecated` in its module.json.
71
+ expect(focusForShort("agent", "deprecated")).toBe("deprecated");
72
+ });
73
+
74
+ test("deprecated shorts stay resolvable (discoverable) — back-compat for existing installs", () => {
75
+ // The deprecated tier de-emphasizes + drops the fresh-install OFFER; it does
76
+ // NOT remove the short from the resolution surface, so an existing
77
+ // notes/runner install keeps routing + lifecycle.
78
+ const shorts = discoverableShorts();
79
+ expect(shorts).toContain("notes");
80
+ expect(shorts).toContain("runner");
81
+ expect(isKnownModuleShort("notes")).toBe(true);
82
+ expect(isKnownModuleShort("runner")).toBe(true);
83
+ });
64
84
  });
65
85
 
66
86
  describe("isKnownModuleShort", () => {
@@ -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 -----------------------------------------
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { InstallOpts } from "../commands/install.ts";
6
- import { parseServicePicks, setup } from "../commands/setup.ts";
6
+ import { isOfferable, parseServicePicks, setup } from "../commands/setup.ts";
7
7
  import { upsertService } from "../services-manifest.ts";
8
8
 
9
9
  interface InstallCall {
@@ -110,6 +110,26 @@ describe("parseServicePicks", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
114
+ test("offers an uninstalled core/experimental module", () => {
115
+ expect(isOfferable({ short: "vault", installed: false })).toBe(true);
116
+ expect(isOfferable({ short: "scribe", installed: false })).toBe(true);
117
+ expect(isOfferable({ short: "surface", installed: false })).toBe(true);
118
+ // agent stays a legit experimental preview — still offered.
119
+ expect(isOfferable({ short: "agent", installed: false })).toBe(true);
120
+ });
121
+
122
+ test("does NOT offer a deprecated module (notes / runner) on a fresh install", () => {
123
+ expect(isOfferable({ short: "notes", installed: false })).toBe(false);
124
+ expect(isOfferable({ short: "runner", installed: false })).toBe(false);
125
+ });
126
+
127
+ test("never offers an already-installed module regardless of tier", () => {
128
+ expect(isOfferable({ short: "vault", installed: true })).toBe(false);
129
+ expect(isOfferable({ short: "notes", installed: true })).toBe(false);
130
+ });
131
+ });
132
+
113
133
  describe("setup", () => {
114
134
  test("exits 0 with friendly note when every known service is installed", async () => {
115
135
  const h = makeHarness();
@@ -155,6 +175,96 @@ describe("setup", () => {
155
175
  }
156
176
  });
157
177
 
178
+ test("fresh box: the offered 'Available to install' list excludes deprecated notes/runner (2026-06-25)", async () => {
179
+ const h = makeHarness();
180
+ try {
181
+ // 'all' picks every OFFERED service. With a clean services.json the survey
182
+ // sees every known short; the offered filter must drop notes + runner
183
+ // (deprecated) while keeping vault/scribe/surface/agent. Only vault +
184
+ // scribe have pre-install follow-up prompts (vault name, scribe provider);
185
+ // surface + agent have none — so the scripted answers below are complete.
186
+ const availability = scriptedAvailability([
187
+ "all", // pick everything offered
188
+ "default", // vault name (vault is in the offered set)
189
+ "1", // scribe provider
190
+ ]);
191
+ const code = await setup({
192
+ manifestPath: h.manifestPath,
193
+ configDir: h.configDir,
194
+ log: (l) => h.logs.push(l),
195
+ availability,
196
+ installFn: async (short, opts) => {
197
+ h.calls.push({ short, opts });
198
+ return 0;
199
+ },
200
+ });
201
+ expect(code).toBe(0);
202
+ // The "Available to install" banner must NOT list notes / runner.
203
+ const joined = h.logs.join("\n");
204
+ const availableBlock = joined.slice(joined.indexOf("Available to install:"));
205
+ expect(availableBlock).not.toMatch(/\bnotes\b/);
206
+ expect(availableBlock).not.toMatch(/\brunner\b/);
207
+ // …and `install()` is never invoked for the deprecated shorts.
208
+ const installedShorts = h.calls.map((c) => c.short);
209
+ expect(installedShorts).not.toContain("notes");
210
+ expect(installedShorts).not.toContain("runner");
211
+ // The non-deprecated set is still offered + installed.
212
+ expect(installedShorts).toContain("vault");
213
+ expect(installedShorts).toContain("scribe");
214
+ expect(installedShorts).toContain("surface");
215
+ expect(installedShorts).toContain("agent");
216
+ } finally {
217
+ h.cleanup();
218
+ }
219
+ });
220
+
221
+ test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
222
+ const h = makeHarness();
223
+ try {
224
+ // Legacy operator with runner (deprecated) on disk. It must surface in the
225
+ // "Already installed" banner (so they know it's there + can manage it via
226
+ // `parachute <verb> runner`), and must NOT reappear in the fresh-install
227
+ // OFFER list.
228
+ upsertService(
229
+ {
230
+ name: "parachute-runner",
231
+ version: "0.2.0",
232
+ port: 1945,
233
+ paths: ["/runner"],
234
+ health: "/runner/healthz",
235
+ },
236
+ h.manifestPath,
237
+ );
238
+ const availability = scriptedAvailability([
239
+ "surface", // pick a still-offered module
240
+ ]);
241
+ const code = await setup({
242
+ manifestPath: h.manifestPath,
243
+ configDir: h.configDir,
244
+ log: (l) => h.logs.push(l),
245
+ availability,
246
+ installFn: async (short, opts) => {
247
+ h.calls.push({ short, opts });
248
+ return 0;
249
+ },
250
+ });
251
+ expect(code).toBe(0);
252
+ const joined = h.logs.join("\n");
253
+ // Banner lists runner as already installed…
254
+ const installedBlock = joined.slice(
255
+ joined.indexOf("Already installed:"),
256
+ joined.indexOf("Available to install:"),
257
+ );
258
+ expect(installedBlock).toMatch(/\brunner\b/);
259
+ // …but runner is NOT in the fresh-install offer.
260
+ const availableBlock = joined.slice(joined.indexOf("Available to install:"));
261
+ expect(availableBlock).not.toMatch(/\brunner\b/);
262
+ expect(h.calls.map((c) => c.short)).not.toContain("runner");
263
+ } finally {
264
+ h.cleanup();
265
+ }
266
+ });
267
+
158
268
  test("rejects non-TTY when there's work to offer", async () => {
159
269
  const h = makeHarness();
160
270
  try {
@@ -220,7 +330,9 @@ describe("setup", () => {
220
330
  const h = makeHarness();
221
331
  try {
222
332
  const availability = scriptedAvailability([
223
- "notes", // single pick — no follow-up prompts
333
+ // surfacea non-deprecated module with no follow-up prompts. (notes,
334
+ // the prior pick, is now `deprecated` → not in the offered set.)
335
+ "surface",
224
336
  ]);
225
337
  const code = await setup({
226
338
  manifestPath: h.manifestPath,
@@ -231,10 +343,10 @@ describe("setup", () => {
231
343
  h.calls.push({ short, opts });
232
344
  upsertService(
233
345
  {
234
- name: "parachute-notes",
346
+ name: "parachute-surface",
235
347
  version: "0.1.0",
236
- port: 1942,
237
- paths: ["/notes"],
348
+ port: 1946,
349
+ paths: ["/surface"],
238
350
  health: "/health",
239
351
  },
240
352
  opts.manifestPath ?? h.manifestPath,
@@ -257,7 +369,8 @@ describe("setup", () => {
257
369
  const h = makeHarness();
258
370
  try {
259
371
  const availability = scriptedAvailability([
260
- "vault, notes", // multi-select
372
+ // surface replaces the prior `notes` pick (now deprecated → not offered).
373
+ "vault, surface", // multi-select
261
374
  "default", // vault name
262
375
  ]);
263
376
  const code = await setup({
@@ -270,10 +383,10 @@ describe("setup", () => {
270
383
  if (short === "vault") return 7; // fail vault
271
384
  upsertService(
272
385
  {
273
- name: "parachute-notes",
386
+ name: "parachute-surface",
274
387
  version: "0.1.0",
275
- port: 1942,
276
- paths: ["/notes"],
388
+ port: 1946,
389
+ paths: ["/surface"],
277
390
  health: "/health",
278
391
  },
279
392
  opts.manifestPath ?? h.manifestPath,
@@ -282,7 +395,7 @@ describe("setup", () => {
282
395
  },
283
396
  });
284
397
  expect(code).toBe(7);
285
- expect(h.calls.map((c) => c.short)).toEqual(["vault", "notes"]);
398
+ expect(h.calls.map((c) => c.short)).toEqual(["vault", "surface"]);
286
399
  expect(h.logs.join("\n")).toMatch(/non-zero exit code/);
287
400
  } finally {
288
401
  h.cleanup();
@@ -295,7 +408,8 @@ describe("setup", () => {
295
408
  const availability = scriptedAvailability([
296
409
  "9", // out-of-range index — loop re-prompts
297
410
  "nope", // unknown name — loop re-prompts again
298
- "notes", // single pick, no follow-up prompts
411
+ // surface — a non-deprecated single pick with no follow-up prompts.
412
+ "surface",
299
413
  ]);
300
414
  const code = await setup({
301
415
  manifestPath: h.manifestPath,
@@ -306,10 +420,10 @@ describe("setup", () => {
306
420
  h.calls.push({ short, opts });
307
421
  upsertService(
308
422
  {
309
- name: "parachute-notes",
423
+ name: "parachute-surface",
310
424
  version: "0.1.0",
311
- port: 1942,
312
- paths: ["/notes"],
425
+ port: 1946,
426
+ paths: ["/surface"],
313
427
  health: "/health",
314
428
  },
315
429
  opts.manifestPath ?? h.manifestPath,
@@ -318,7 +432,7 @@ describe("setup", () => {
318
432
  },
319
433
  });
320
434
  expect(code).toBe(0);
321
- expect(h.calls.map((c) => c.short)).toEqual(["notes"]);
435
+ expect(h.calls.map((c) => c.short)).toEqual(["surface"]);
322
436
  expect(availability.remaining()).toBe(0);
323
437
  const joined = h.logs.join("\n");
324
438
  expect(joined).toMatch(/out-of-range/);