@openparachute/hub 0.6.3 → 0.6.4-rc.1
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__/account-setup.test.ts +609 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -6,12 +6,34 @@ import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-to
|
|
|
6
6
|
import {
|
|
7
7
|
formatBootstrapTokenBanner,
|
|
8
8
|
formatListeningBanner,
|
|
9
|
+
hubPortConflictMessage,
|
|
9
10
|
resolveStartupIssuer,
|
|
10
11
|
seedInitialAdminIfNeeded,
|
|
11
12
|
} from "../commands/serve.ts";
|
|
12
13
|
import { openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { getUserByUsername, userCount } from "../users.ts";
|
|
14
15
|
|
|
16
|
+
describe("hubPortConflictMessage (hub#536)", () => {
|
|
17
|
+
test("maps a port-in-use error to a clear duplicate-supervisor message", () => {
|
|
18
|
+
// Bun surfaces a port conflict as "...Is port 1939 in use?"; node-style is
|
|
19
|
+
// "EADDRINUSE: address already in use". Both must map to the clear message.
|
|
20
|
+
for (const m of [
|
|
21
|
+
"EADDRINUSE: address already in use 127.0.0.1:1939",
|
|
22
|
+
"Failed to start server. Is port 1939 in use?",
|
|
23
|
+
]) {
|
|
24
|
+
const out = hubPortConflictMessage(new Error(m), 1939);
|
|
25
|
+
expect(out).toContain("already in use");
|
|
26
|
+
expect(out).toContain("duplicate supervisor");
|
|
27
|
+
expect(out).toContain("1939");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns null for an unrelated error (caller re-throws the original)", () => {
|
|
32
|
+
expect(hubPortConflictMessage(new Error("permission denied"), 1939)).toBeNull();
|
|
33
|
+
expect(hubPortConflictMessage("not even an Error", 1939)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
15
37
|
describe("seedInitialAdminIfNeeded", () => {
|
|
16
38
|
let dir: string;
|
|
17
39
|
let dbPath: string;
|
|
@@ -248,6 +270,13 @@ describe("bootstrap-token wiring under needs-setup", () => {
|
|
|
248
270
|
});
|
|
249
271
|
|
|
250
272
|
describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
273
|
+
// Stub "no exposure recorded" so the undefined-asserting tests are
|
|
274
|
+
// isolated from the host's real ~/.parachute/expose-state.json — the
|
|
275
|
+
// default reader picks up a live exposure on the dev box and the expose
|
|
276
|
+
// tier (#531) would otherwise shadow the "no source → undefined" cases.
|
|
277
|
+
// The expose tier itself is exercised in its own describe block below.
|
|
278
|
+
const noExpose = (): string | undefined => undefined;
|
|
279
|
+
|
|
251
280
|
test("explicit opts.issuer wins over everything", () => {
|
|
252
281
|
const got = resolveStartupIssuer(
|
|
253
282
|
{ issuer: "https://override.example" },
|
|
@@ -296,9 +325,9 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
296
325
|
});
|
|
297
326
|
|
|
298
327
|
test("returns undefined when no source has a value", () => {
|
|
299
|
-
expect(resolveStartupIssuer({}, {})).toBeUndefined();
|
|
300
|
-
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "" })).toBeUndefined();
|
|
301
|
-
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "" })).toBeUndefined();
|
|
328
|
+
expect(resolveStartupIssuer({}, {}, noExpose)).toBeUndefined();
|
|
329
|
+
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "" }, noExpose)).toBeUndefined();
|
|
330
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "" }, noExpose)).toBeUndefined();
|
|
302
331
|
});
|
|
303
332
|
|
|
304
333
|
test("empty string after slash-strip collapses to undefined (defensive)", () => {
|
|
@@ -306,7 +335,7 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
306
335
|
// Guards against a misconfigured env where someone sets the var to "/"
|
|
307
336
|
// expecting it to mean "root" (it doesn't — leaves hub without a usable
|
|
308
337
|
// origin, which is the same as not setting it at all).
|
|
309
|
-
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" })).toBeUndefined();
|
|
338
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" }, noExpose)).toBeUndefined();
|
|
310
339
|
});
|
|
311
340
|
|
|
312
341
|
// Fly.io self-host path (patterns#100). resolveStartupIssuer is the
|
|
@@ -341,11 +370,96 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
341
370
|
});
|
|
342
371
|
|
|
343
372
|
test("FLY_APP_NAME with slash rejected (defensive — Fly slugs don't contain /)", () => {
|
|
344
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "a/b" })).toBeUndefined();
|
|
345
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "../etc/passwd" })).toBeUndefined();
|
|
373
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "a/b" }, noExpose)).toBeUndefined();
|
|
374
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "../etc/passwd" }, noExpose)).toBeUndefined();
|
|
346
375
|
});
|
|
347
376
|
|
|
348
377
|
test("FLY_APP_NAME empty string → no fallback", () => {
|
|
349
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" })).toBeUndefined();
|
|
378
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
|
|
383
|
+
// The reboot-persistent bug: the launchd plist / systemd unit that keeps
|
|
384
|
+
// `parachute serve` alive carries no PARACHUTE_HUB_ORIGIN, so on every
|
|
385
|
+
// reboot the hub boots with no flag/env/platform origin and would stamp
|
|
386
|
+
// iss from the per-request origin — which exposed resource servers (vault)
|
|
387
|
+
// reject until they restart. Reading expose-state.json's hubOrigin makes
|
|
388
|
+
// iss deterministic across reboots. The readExpose seam lets us drive this
|
|
389
|
+
// without touching the real ~/.parachute.
|
|
390
|
+
const EXPOSED = "https://parachute.taildf9ce2.ts.net";
|
|
391
|
+
|
|
392
|
+
test("returns expose-state.hubOrigin when no flag/env/platform set", () => {
|
|
393
|
+
const got = resolveStartupIssuer({}, {}, () => EXPOSED);
|
|
394
|
+
expect(got).toBe(EXPOSED);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("explicit opts.issuer wins over expose-state", () => {
|
|
398
|
+
const got = resolveStartupIssuer({ issuer: "https://override.example" }, {}, () => EXPOSED);
|
|
399
|
+
expect(got).toBe("https://override.example");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("PARACHUTE_HUB_ORIGIN wins over expose-state", () => {
|
|
403
|
+
const got = resolveStartupIssuer(
|
|
404
|
+
{},
|
|
405
|
+
{ PARACHUTE_HUB_ORIGIN: "https://env.example" },
|
|
406
|
+
() => EXPOSED,
|
|
407
|
+
);
|
|
408
|
+
expect(got).toBe("https://env.example");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("RENDER_EXTERNAL_URL wins over expose-state", () => {
|
|
412
|
+
const got = resolveStartupIssuer(
|
|
413
|
+
{},
|
|
414
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
415
|
+
() => EXPOSED,
|
|
416
|
+
);
|
|
417
|
+
expect(got).toBe("https://app.onrender.com");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("FLY_APP_NAME wins over expose-state", () => {
|
|
421
|
+
const got = resolveStartupIssuer({}, { FLY_APP_NAME: "my-parachute" }, () => EXPOSED);
|
|
422
|
+
expect(got).toBe("https://my-parachute.fly.dev");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("returns undefined when expose-state is absent (no hubOrigin recorded)", () => {
|
|
426
|
+
expect(resolveStartupIssuer({}, {}, () => undefined)).toBeUndefined();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("strips trailing slashes from the expose origin", () => {
|
|
430
|
+
expect(resolveStartupIssuer({}, {}, () => `${EXPOSED}/`)).toBe(EXPOSED);
|
|
431
|
+
expect(resolveStartupIssuer({}, {}, () => `${EXPOSED}//`)).toBe(EXPOSED);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("ignores a loopback expose hubOrigin (never re-pin the degraded mode)", () => {
|
|
435
|
+
expect(resolveStartupIssuer({}, {}, () => "http://127.0.0.1:1939")).toBeUndefined();
|
|
436
|
+
expect(resolveStartupIssuer({}, {}, () => "http://localhost:1939")).toBeUndefined();
|
|
437
|
+
expect(resolveStartupIssuer({}, {}, () => "http://[::1]:1939")).toBeUndefined();
|
|
438
|
+
expect(resolveStartupIssuer({}, {}, () => "http://0.0.0.0:1939")).toBeUndefined();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("ignores a non-http(s) or malformed expose origin", () => {
|
|
442
|
+
expect(resolveStartupIssuer({}, {}, () => "ftp://parachute.example")).toBeUndefined();
|
|
443
|
+
expect(resolveStartupIssuer({}, {}, () => "not-a-url")).toBeUndefined();
|
|
444
|
+
expect(resolveStartupIssuer({}, {}, () => "")).toBeUndefined();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("default reader is swallowed-safe (no real ~/.parachute) — does not throw", () => {
|
|
448
|
+
// The default readExpose swallows a malformed-file throw; with no
|
|
449
|
+
// exposure recorded under the test PARACHUTE_HOME it just yields
|
|
450
|
+
// undefined → undefined issuer. The key assertion is "doesn't throw."
|
|
451
|
+
expect(() => resolveStartupIssuer({}, {})).not.toThrow();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a throwing injected reader can't crash startup (returns undefined)", () => {
|
|
455
|
+
// The readExpose() call is try/catch-wrapped so even a non-swallowing
|
|
456
|
+
// reader can't propagate into boot. With no flag/env/platform origin set
|
|
457
|
+
// and a throwing reader, the issuer resolves to undefined (the degraded
|
|
458
|
+
// request-origin mode) rather than crashing `parachute serve`.
|
|
459
|
+
const throwing = () => {
|
|
460
|
+
throw new Error("malformed expose-state.json");
|
|
461
|
+
};
|
|
462
|
+
expect(() => resolveStartupIssuer({}, {}, throwing)).not.toThrow();
|
|
463
|
+
expect(resolveStartupIssuer({}, {}, throwing)).toBeUndefined();
|
|
350
464
|
});
|
|
351
465
|
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type EnrichedPathDeps,
|
|
4
|
+
enrichedPath,
|
|
5
|
+
enrichedUnitPath,
|
|
6
|
+
operatorToolDirs,
|
|
7
|
+
} from "../spawn-path.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build EnrichedPathDeps with a fake fs (set of existing paths) + pinned
|
|
11
|
+
* platform/arch/home so tests never touch the real disk or host.
|
|
12
|
+
*/
|
|
13
|
+
function fakeDeps(opts: {
|
|
14
|
+
home?: string;
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
arch?: string;
|
|
17
|
+
existing?: string[];
|
|
18
|
+
}): EnrichedPathDeps {
|
|
19
|
+
const existing = new Set(opts.existing ?? []);
|
|
20
|
+
return {
|
|
21
|
+
homeDir: () => opts.home ?? "/home/op",
|
|
22
|
+
exists: (p) => existing.has(p),
|
|
23
|
+
platform: opts.platform ?? "darwin",
|
|
24
|
+
arch: opts.arch ?? "arm64",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("enrichedPath — operator-tool PATH enrichment", () => {
|
|
29
|
+
test("preserves the inherited PATH and keeps its order", () => {
|
|
30
|
+
const deps = fakeDeps({ existing: [] });
|
|
31
|
+
const result = enrichedPath({ PATH: "/usr/bin:/bin" }, deps);
|
|
32
|
+
expect(result).toBe("/usr/bin:/bin");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("appends operator-tool dirs only when they exist on disk", () => {
|
|
36
|
+
// .local/bin + brew exist; .bun/bin does NOT → only the two existing append.
|
|
37
|
+
const deps = fakeDeps({
|
|
38
|
+
home: "/home/op",
|
|
39
|
+
platform: "darwin",
|
|
40
|
+
arch: "arm64",
|
|
41
|
+
existing: ["/home/op/.local/bin", "/opt/homebrew/bin"],
|
|
42
|
+
});
|
|
43
|
+
const result = enrichedPath({ PATH: "/usr/bin" }, deps);
|
|
44
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin:/opt/homebrew/bin");
|
|
45
|
+
expect(result).not.toContain("/home/op/.bun/bin");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("inherited PATH wins over appended defaults (append, not prepend)", () => {
|
|
49
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
50
|
+
const result = enrichedPath({ PATH: "/first:/second" }, deps);
|
|
51
|
+
const parts = result.split(":");
|
|
52
|
+
expect(parts[0]).toBe("/first");
|
|
53
|
+
expect(parts[1]).toBe("/second");
|
|
54
|
+
expect(parts[2]).toBe("/home/op/.local/bin");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("dedupes — an appended dir already in the inherited PATH is not duplicated", () => {
|
|
58
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
59
|
+
const result = enrichedPath({ PATH: "/usr/bin:/home/op/.local/bin" }, deps);
|
|
60
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin");
|
|
61
|
+
expect(result.split(":").filter((p) => p === "/home/op/.local/bin")).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("PARACHUTE_EXTRA_PATH is PREPENDED so an operator can intentionally shadow", () => {
|
|
65
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
66
|
+
const result = enrichedPath(
|
|
67
|
+
{ PATH: "/usr/bin", PARACHUTE_EXTRA_PATH: "/opt/custom/bin:/opt/more" },
|
|
68
|
+
deps,
|
|
69
|
+
);
|
|
70
|
+
const parts = result.split(":");
|
|
71
|
+
expect(parts[0]).toBe("/opt/custom/bin");
|
|
72
|
+
expect(parts[1]).toBe("/opt/more");
|
|
73
|
+
expect(parts[2]).toBe("/usr/bin");
|
|
74
|
+
expect(parts[3]).toBe("/home/op/.local/bin");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("PARACHUTE_EXTRA_PATH dedupes against inherited (extra-first wins position)", () => {
|
|
78
|
+
const deps = fakeDeps({ existing: [] });
|
|
79
|
+
const result = enrichedPath({ PATH: "/usr/bin:/dup", PARACHUTE_EXTRA_PATH: "/dup" }, deps);
|
|
80
|
+
expect(result).toBe("/dup:/usr/bin");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("empty inherited PATH yields just the existing appended dirs", () => {
|
|
84
|
+
const deps = fakeDeps({
|
|
85
|
+
home: "/home/op",
|
|
86
|
+
platform: "darwin",
|
|
87
|
+
arch: "arm64",
|
|
88
|
+
existing: ["/home/op/.local/bin", "/opt/homebrew/bin", "/home/op/.bun/bin"],
|
|
89
|
+
});
|
|
90
|
+
const result = enrichedPath({ PATH: "" }, deps);
|
|
91
|
+
expect(result).toBe("/home/op/.local/bin:/opt/homebrew/bin:/home/op/.bun/bin");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("platform / arch branches", () => {
|
|
95
|
+
test("darwin arm64 → /opt/homebrew/bin", () => {
|
|
96
|
+
const deps = fakeDeps({
|
|
97
|
+
platform: "darwin",
|
|
98
|
+
arch: "arm64",
|
|
99
|
+
existing: ["/opt/homebrew/bin"],
|
|
100
|
+
});
|
|
101
|
+
expect(enrichedPath({ PATH: "/usr/bin" }, deps)).toBe("/usr/bin:/opt/homebrew/bin");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("darwin x64 → /usr/local/bin (Intel brew)", () => {
|
|
105
|
+
const deps = fakeDeps({
|
|
106
|
+
platform: "darwin",
|
|
107
|
+
arch: "x64",
|
|
108
|
+
existing: ["/usr/local/bin"],
|
|
109
|
+
});
|
|
110
|
+
// /usr/local/bin is the brew bin on Intel macOS; here it's the only existing dir.
|
|
111
|
+
expect(enrichedPath({ PATH: "/usr/bin" }, deps)).toBe("/usr/bin:/usr/local/bin");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("linux → only $HOME/.local/bin + $HOME/.bun/bin, no brew bin", () => {
|
|
115
|
+
const deps = fakeDeps({
|
|
116
|
+
home: "/home/op",
|
|
117
|
+
platform: "linux",
|
|
118
|
+
arch: "x64",
|
|
119
|
+
existing: ["/home/op/.local/bin", "/home/op/.bun/bin", "/opt/homebrew/bin"],
|
|
120
|
+
});
|
|
121
|
+
const result = enrichedPath({ PATH: "/usr/bin" }, deps);
|
|
122
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin:/home/op/.bun/bin");
|
|
123
|
+
// /opt/homebrew/bin exists in the fake fs but is NOT a Linux candidate dir.
|
|
124
|
+
expect(result).not.toContain("/opt/homebrew/bin");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("operatorToolDirs — candidate dir list", () => {
|
|
130
|
+
test("darwin arm64: .local/bin, /opt/homebrew/bin, .bun/bin (in order)", () => {
|
|
131
|
+
expect(operatorToolDirs("/home/op", "darwin", "arm64")).toEqual([
|
|
132
|
+
"/home/op/.local/bin",
|
|
133
|
+
"/opt/homebrew/bin",
|
|
134
|
+
"/home/op/.bun/bin",
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("darwin x64: brew is /usr/local/bin", () => {
|
|
139
|
+
expect(operatorToolDirs("/home/op", "darwin", "x64")).toEqual([
|
|
140
|
+
"/home/op/.local/bin",
|
|
141
|
+
"/usr/local/bin",
|
|
142
|
+
"/home/op/.bun/bin",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("linux: no brew dir", () => {
|
|
147
|
+
expect(operatorToolDirs("/home/op", "linux", "x64")).toEqual([
|
|
148
|
+
"/home/op/.local/bin",
|
|
149
|
+
"/home/op/.bun/bin",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("enrichedUnitPath — launchd/systemd unit PATH", () => {
|
|
155
|
+
test("bun bin first, then system dirs, then operator-tool dirs (darwin arm64)", () => {
|
|
156
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "arm64", undefined);
|
|
157
|
+
expect(result).toBe(
|
|
158
|
+
"/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin:/opt/homebrew/bin",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("dedupes — Intel brew /usr/local/bin already present in the base is not duplicated", () => {
|
|
163
|
+
// darwin x64 brew dir == /usr/local/bin, which the base already carries.
|
|
164
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "x64", undefined);
|
|
165
|
+
expect(result.split(":").filter((p) => p === "/usr/local/bin")).toHaveLength(1);
|
|
166
|
+
expect(result).toBe("/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("linux: appends $HOME/.local/bin (no brew dir)", () => {
|
|
170
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "linux", "x64", undefined);
|
|
171
|
+
expect(result).toBe("/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("includes operator-tool dirs unconditionally (no existence check)", () => {
|
|
175
|
+
// No fs seam here — the unit-side dirs are baked in even if absent on disk.
|
|
176
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "arm64", undefined);
|
|
177
|
+
expect(result).toContain("/home/op/.local/bin");
|
|
178
|
+
expect(result).toContain("/opt/homebrew/bin");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("PARACHUTE_EXTRA_PATH is prepended", () => {
|
|
182
|
+
const result = enrichedUnitPath(
|
|
183
|
+
"/home/op/.bun",
|
|
184
|
+
"/home/op",
|
|
185
|
+
"darwin",
|
|
186
|
+
"arm64",
|
|
187
|
+
"/opt/custom/bin",
|
|
188
|
+
);
|
|
189
|
+
expect(result.split(":")[0]).toBe("/opt/custom/bin");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -256,4 +256,68 @@ describe("status — per-module URL deep-links (manifestRowBase / urlForEntry)",
|
|
|
256
256
|
cleanup();
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
test("non-curated supervised module reads `active` via the `supervised` fallback — hub#539", async () => {
|
|
261
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
262
|
+
try {
|
|
263
|
+
// surface is supervised but absent from the curated `modules` catalog
|
|
264
|
+
// (which only carries vault/scribe). Before hub#539 it mapped to
|
|
265
|
+
// `inactive` despite running; now `status` falls back to `supervised`.
|
|
266
|
+
upsertService(
|
|
267
|
+
{
|
|
268
|
+
name: "parachute-surface",
|
|
269
|
+
port: 1946,
|
|
270
|
+
paths: ["/surface"],
|
|
271
|
+
health: "/surface/healthz",
|
|
272
|
+
version: "0.2.2",
|
|
273
|
+
},
|
|
274
|
+
path,
|
|
275
|
+
);
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
await status({
|
|
278
|
+
...supervisorOpts(configDir, path, {
|
|
279
|
+
// `modules` empty (curated catalog omits surface); run-state ONLY in
|
|
280
|
+
// `supervised` — exactly the wire shape the live hub now returns.
|
|
281
|
+
moduleStates: {
|
|
282
|
+
supervisorAvailable: true,
|
|
283
|
+
modules: [],
|
|
284
|
+
supervised: [runningModule("surface")],
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
print: (l) => lines.push(l),
|
|
288
|
+
});
|
|
289
|
+
const surfaceLine = lines.find((l) => l.includes("parachute-surface")) ?? "";
|
|
290
|
+
expect(surfaceLine).toMatch(/\bactive\b/);
|
|
291
|
+
expect(surfaceLine).not.toMatch(/\binactive\b/);
|
|
292
|
+
} finally {
|
|
293
|
+
cleanup();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("module absent from BOTH modules and supervised stays `inactive` — hub#539 boundary", async () => {
|
|
298
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
299
|
+
try {
|
|
300
|
+
upsertService(
|
|
301
|
+
{
|
|
302
|
+
name: "parachute-surface",
|
|
303
|
+
port: 1946,
|
|
304
|
+
paths: ["/surface"],
|
|
305
|
+
health: "/surface/healthz",
|
|
306
|
+
version: "0.2.2",
|
|
307
|
+
},
|
|
308
|
+
path,
|
|
309
|
+
);
|
|
310
|
+
const lines: string[] = [];
|
|
311
|
+
await status({
|
|
312
|
+
...supervisorOpts(configDir, path, {
|
|
313
|
+
moduleStates: { supervisorAvailable: true, modules: [], supervised: [] },
|
|
314
|
+
}),
|
|
315
|
+
print: (l) => lines.push(l),
|
|
316
|
+
});
|
|
317
|
+
const surfaceLine = lines.find((l) => l.includes("parachute-surface")) ?? "";
|
|
318
|
+
expect(surfaceLine).toMatch(/\binactive\b/);
|
|
319
|
+
} finally {
|
|
320
|
+
cleanup();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
259
323
|
});
|
|
@@ -595,6 +595,113 @@ describe("Supervisor.restart", () => {
|
|
|
595
595
|
sup.stop("vault");
|
|
596
596
|
second.resolveExit(0);
|
|
597
597
|
});
|
|
598
|
+
|
|
599
|
+
test("replays entry.req when no nextReq is supplied", async () => {
|
|
600
|
+
const first = makeFakeProc(101);
|
|
601
|
+
const second = makeFakeProc(102);
|
|
602
|
+
const spawner = makeQueueSpawner();
|
|
603
|
+
spawner.enqueue(first);
|
|
604
|
+
spawner.enqueue(second);
|
|
605
|
+
|
|
606
|
+
const sup = new Supervisor({
|
|
607
|
+
spawnFn: spawner.spawn,
|
|
608
|
+
killFn: noopKill,
|
|
609
|
+
restartDelayMs: 0,
|
|
610
|
+
sleep: () => Promise.resolve(),
|
|
611
|
+
});
|
|
612
|
+
await sup.start({
|
|
613
|
+
short: "vault",
|
|
614
|
+
cmd: ["bun", "vault.ts"],
|
|
615
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://origin.example" },
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const restartPromise = sup.restart("vault");
|
|
619
|
+
first.closeStreams();
|
|
620
|
+
first.resolveExit(0);
|
|
621
|
+
await restartPromise;
|
|
622
|
+
|
|
623
|
+
// No nextReq → the re-spawn replays the original env (legacy behavior).
|
|
624
|
+
expect(spawner.calls[1]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://origin.example");
|
|
625
|
+
expect(spawner.calls[1]?.cmd).toEqual(["bun", "vault.ts"]);
|
|
626
|
+
|
|
627
|
+
second.closeStreams();
|
|
628
|
+
sup.stop("vault");
|
|
629
|
+
second.resolveExit(0);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("nextReq re-spawns with the refreshed req AND propagates to crash-restart (hub#532)", async () => {
|
|
633
|
+
const first = makeFakeProc(101);
|
|
634
|
+
const second = makeFakeProc(102); // restart re-spawn
|
|
635
|
+
const third = makeFakeProc(103); // crash-restart respawn
|
|
636
|
+
const spawner = makeQueueSpawner();
|
|
637
|
+
spawner.enqueue(first);
|
|
638
|
+
spawner.enqueue(second);
|
|
639
|
+
spawner.enqueue(third);
|
|
640
|
+
|
|
641
|
+
const sup = new Supervisor({
|
|
642
|
+
spawnFn: spawner.spawn,
|
|
643
|
+
killFn: noopKill,
|
|
644
|
+
restartDelayMs: 0,
|
|
645
|
+
sleep: () => Promise.resolve(),
|
|
646
|
+
});
|
|
647
|
+
// First start with the OLD origin.
|
|
648
|
+
await sup.start({
|
|
649
|
+
short: "vault",
|
|
650
|
+
cmd: ["bun", "vault.ts"],
|
|
651
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://old.example" },
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Restart WITH a refreshed req carrying the NEW origin.
|
|
655
|
+
const restartPromise = sup.restart("vault", {
|
|
656
|
+
short: "vault",
|
|
657
|
+
cmd: ["bun", "vault.ts"],
|
|
658
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://new.example" },
|
|
659
|
+
});
|
|
660
|
+
first.closeStreams();
|
|
661
|
+
first.resolveExit(0);
|
|
662
|
+
await restartPromise;
|
|
663
|
+
|
|
664
|
+
// The restart re-spawn carries the NEW origin.
|
|
665
|
+
expect(spawner.calls[1]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://new.example");
|
|
666
|
+
|
|
667
|
+
// Now crash the restart-spawned child (resolveExit with a non-stop code).
|
|
668
|
+
// handleExit → spawnAndWatch replays entry.req, which `start` stored from
|
|
669
|
+
// the refreshed nextReq — so the crash-restart ALSO carries the new origin.
|
|
670
|
+
second.closeStreams();
|
|
671
|
+
second.resolveExit(1);
|
|
672
|
+
await tick(20);
|
|
673
|
+
|
|
674
|
+
expect(spawner.calls).toHaveLength(3);
|
|
675
|
+
expect(spawner.calls[2]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://new.example");
|
|
676
|
+
|
|
677
|
+
third.closeStreams();
|
|
678
|
+
sup.stop("vault");
|
|
679
|
+
third.resolveExit(0);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("throws when nextReq.short mismatches the restarted short (state-corruption guard)", async () => {
|
|
683
|
+
const proc = makeFakeProc(101);
|
|
684
|
+
const spawner = makeQueueSpawner();
|
|
685
|
+
spawner.enqueue(proc);
|
|
686
|
+
const sup = new Supervisor({
|
|
687
|
+
spawnFn: spawner.spawn,
|
|
688
|
+
killFn: noopKill,
|
|
689
|
+
restartDelayMs: 0,
|
|
690
|
+
sleep: () => Promise.resolve(),
|
|
691
|
+
});
|
|
692
|
+
await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
|
|
693
|
+
|
|
694
|
+
// A nextReq for a DIFFERENT short would re-register under the wrong map key.
|
|
695
|
+
await expect(
|
|
696
|
+
sup.restart("vault", { short: "scribe", cmd: ["bun", "scribe.ts"] }),
|
|
697
|
+
).rejects.toThrow(/nextReq\.short is "scribe"/);
|
|
698
|
+
// The original entry is untouched — no spurious stop/respawn happened.
|
|
699
|
+
expect(spawner.calls).toHaveLength(1);
|
|
700
|
+
|
|
701
|
+
proc.closeStreams();
|
|
702
|
+
sup.stop("vault");
|
|
703
|
+
proc.resolveExit(0);
|
|
704
|
+
});
|
|
598
705
|
});
|
|
599
706
|
|
|
600
707
|
describe("Supervisor output multiplexing", () => {
|
|
@@ -833,6 +940,76 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
|
|
|
833
940
|
proc.resolveExit(0);
|
|
834
941
|
});
|
|
835
942
|
|
|
943
|
+
test("(b2) late bind AFTER the window → the background watch clears the started-but-unbound note", async () => {
|
|
944
|
+
// Heavy modules (vault — SQLite + git mirror + well-known init) routinely
|
|
945
|
+
// bind a moment after the readiness window. Pre-fix, the note recorded at
|
|
946
|
+
// window-elapse stuck for the module's whole lifetime and `parachute
|
|
947
|
+
// status` showed a perpetual "failed to start" on a healthy module.
|
|
948
|
+
const proc = makeFakeProc(103);
|
|
949
|
+
const spawner = makeQueueSpawner();
|
|
950
|
+
spawner.enqueue(proc);
|
|
951
|
+
let bound = false;
|
|
952
|
+
const sup = new Supervisor({
|
|
953
|
+
spawnFn: spawner.spawn,
|
|
954
|
+
killFn: noopKill,
|
|
955
|
+
portListening: async () => bound,
|
|
956
|
+
startReadyMs: 30,
|
|
957
|
+
startReadyPollMs: 5,
|
|
958
|
+
lateBindWatchMs: 2_000,
|
|
959
|
+
lateBindPollMs: 5,
|
|
960
|
+
sleep: () => Promise.resolve(),
|
|
961
|
+
});
|
|
962
|
+
const state = await sup.start(reqWithPort("vault", 1940));
|
|
963
|
+
// Window elapsed unbound → note recorded (status stays running)…
|
|
964
|
+
expect(state.startError?.error_type).toBe("started_but_unbound");
|
|
965
|
+
|
|
966
|
+
// …then the port binds late. The watch must clear the note.
|
|
967
|
+
bound = true;
|
|
968
|
+
let cleared = false;
|
|
969
|
+
const deadline = Date.now() + 1_500;
|
|
970
|
+
while (Date.now() < deadline) {
|
|
971
|
+
if (sup.list()[0]?.startError === undefined) {
|
|
972
|
+
cleared = true;
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
976
|
+
}
|
|
977
|
+
expect(cleared).toBe(true);
|
|
978
|
+
expect(sup.list()[0]?.status).toBe("running");
|
|
979
|
+
|
|
980
|
+
proc.closeStreams();
|
|
981
|
+
sup.stop("vault");
|
|
982
|
+
proc.resolveExit(0);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("(b3) never binds → the watch gives up at its deadline and the note persists", async () => {
|
|
986
|
+
// A genuinely-unbound module must KEEP its diagnostic — the watch is a
|
|
987
|
+
// bounded grace window, not an eraser.
|
|
988
|
+
const proc = makeFakeProc(104);
|
|
989
|
+
const spawner = makeQueueSpawner();
|
|
990
|
+
spawner.enqueue(proc);
|
|
991
|
+
const sup = new Supervisor({
|
|
992
|
+
spawnFn: spawner.spawn,
|
|
993
|
+
killFn: noopKill,
|
|
994
|
+
portListening: async () => false,
|
|
995
|
+
startReadyMs: 20,
|
|
996
|
+
startReadyPollMs: 5,
|
|
997
|
+
lateBindWatchMs: 40,
|
|
998
|
+
lateBindPollMs: 5,
|
|
999
|
+
sleep: () => Promise.resolve(),
|
|
1000
|
+
});
|
|
1001
|
+
const state = await sup.start(reqWithPort("vault", 1940));
|
|
1002
|
+
expect(state.startError?.error_type).toBe("started_but_unbound");
|
|
1003
|
+
|
|
1004
|
+
// Let the 40ms watch budget expire; the note must remain.
|
|
1005
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
1006
|
+
expect(sup.list()[0]?.startError?.error_type).toBe("started_but_unbound");
|
|
1007
|
+
|
|
1008
|
+
proc.closeStreams();
|
|
1009
|
+
sup.stop("vault");
|
|
1010
|
+
proc.resolveExit(0);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
836
1013
|
test("(c) preflight MissingDependencyError → structured start-error, NO spawn", async () => {
|
|
837
1014
|
const spawner = makeQueueSpawner();
|
|
838
1015
|
// No proc enqueued — if start() tried to spawn, the queue spawner throws.
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
7
|
+
import { createSession, findSession } from "../sessions.ts";
|
|
7
8
|
import {
|
|
8
9
|
PASSWORD_MIN_LEN,
|
|
9
10
|
SingleUserModeError,
|
|
@@ -499,6 +500,32 @@ describe("resetUserPassword", () => {
|
|
|
499
500
|
}
|
|
500
501
|
});
|
|
501
502
|
|
|
503
|
+
// Item G — a reset also kills active sessions (not just tokens), so the
|
|
504
|
+
// attacker/holder of a live session cookie must re-authenticate.
|
|
505
|
+
test("deletes the user's active sessions (item G)", async () => {
|
|
506
|
+
const { db, cleanup } = makeDb();
|
|
507
|
+
try {
|
|
508
|
+
const alice = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
509
|
+
passwordChanged: true,
|
|
510
|
+
});
|
|
511
|
+
const bob = await createUser(db, "bob", "bob-strong-passphrase", {
|
|
512
|
+
passwordChanged: true,
|
|
513
|
+
allowMulti: true,
|
|
514
|
+
});
|
|
515
|
+
const aliceSession = createSession(db, { userId: alice.id });
|
|
516
|
+
const bobSession = createSession(db, { userId: bob.id });
|
|
517
|
+
expect(findSession(db, aliceSession.id)).not.toBeNull();
|
|
518
|
+
|
|
519
|
+
expect(await resetUserPassword(db, alice.id, "new-temp-passphrase")).toBe(true);
|
|
520
|
+
|
|
521
|
+
// Alice's session is gone; Bob's (a different user) is untouched.
|
|
522
|
+
expect(findSession(db, aliceSession.id)).toBeNull();
|
|
523
|
+
expect(findSession(db, bobSession.id)).not.toBeNull();
|
|
524
|
+
} finally {
|
|
525
|
+
cleanup();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
502
529
|
test("does not re-revoke an already-revoked token", async () => {
|
|
503
530
|
// Defense-in-depth: a previously-revoked token shouldn't have its
|
|
504
531
|
// revoked_at timestamp overwritten by a fresh reset. The UPDATE's
|