@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.3-rc.2",
3
+ "version": "0.6.3-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
70
70
  return signed.token;
71
71
  }
72
72
 
73
+ /**
74
+ * Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
75
+ * operator token on loopback; after `expose` its `iss` is the public origin
76
+ * while the loopback request resolves the loopback issuer.
77
+ */
78
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
79
+ const signed = await signAccessToken(h.db, {
80
+ sub: h.userId,
81
+ scopes,
82
+ audience: "operator",
83
+ clientId: "parachute-hub",
84
+ issuer: iss,
85
+ ttlSeconds: 3600,
86
+ });
87
+ recordTokenMint(h.db, {
88
+ jti: signed.jti,
89
+ createdVia: "operator_mint",
90
+ subject: "operator",
91
+ clientId: "parachute-hub",
92
+ scopes,
93
+ expiresAt: signed.expiresAt,
94
+ });
95
+ return signed.token;
96
+ }
97
+
98
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
99
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
100
+ /** A foreign origin the hub never answers on. */
101
+ const FOREIGN_ORIGIN = "https://evil.example.com";
102
+
73
103
  function postReq(path: string, headers: Record<string, string>): Request {
74
104
  return new Request(`http://localhost${path}`, { method: "POST", headers });
75
105
  }
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
967
997
  });
968
998
  });
969
999
 
1000
+ /**
1001
+ * hub#516 — the module-ops `authorize` accepts the operator token's `iss`
1002
+ * against the SET of origins the hub answers on (knownIssuers), not just the
1003
+ * single per-request issuer. Exercised through `handleStop` (the simplest sync
1004
+ * op that goes through `authorize`). The per-request `issuer` here is loopback
1005
+ * (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
1006
+ * public expose-state origin.
1007
+ */
1008
+ describe("operator-token iss validation against knownIssuers (hub#516)", () => {
1009
+ let h: Harness;
1010
+ beforeEach(async () => {
1011
+ h = await makeHarness();
1012
+ _resetOperationsRegistryForTests();
1013
+ });
1014
+ afterEach(() => h.cleanup());
1015
+
1016
+ // The live repro: operator token's iss = PUBLIC origin, loopback request
1017
+ // (per-request issuer = loopback), knownIssuers includes the public origin
1018
+ // → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
1019
+ test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
1020
+ const { supervisor } = makeIdleSupervisor();
1021
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1022
+ const res = await handleStop(
1023
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1024
+ "vault",
1025
+ {
1026
+ db: h.db,
1027
+ issuer: ISSUER, // loopback per-request issuer
1028
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1029
+ manifestPath: h.manifestPath,
1030
+ configDir: h.dir,
1031
+ supervisor,
1032
+ },
1033
+ );
1034
+ // Not a 401 — authorize passed. (stopped:false because nothing's running.)
1035
+ expect(res.status).toBe(200);
1036
+ });
1037
+
1038
+ test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
1039
+ const { supervisor } = makeIdleSupervisor();
1040
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
1041
+ const res = await handleStop(
1042
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1043
+ "vault",
1044
+ {
1045
+ db: h.db,
1046
+ issuer: ISSUER,
1047
+ knownIssuers: [ISSUER, "http://localhost:1939"],
1048
+ manifestPath: h.manifestPath,
1049
+ configDir: h.dir,
1050
+ supervisor,
1051
+ },
1052
+ );
1053
+ expect(res.status).toBe(200);
1054
+ });
1055
+
1056
+ test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
1057
+ const { supervisor } = makeIdleSupervisor();
1058
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
1059
+ const res = await handleStop(
1060
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1061
+ "vault",
1062
+ {
1063
+ db: h.db,
1064
+ issuer: ISSUER,
1065
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1066
+ manifestPath: h.manifestPath,
1067
+ configDir: h.dir,
1068
+ supervisor,
1069
+ },
1070
+ );
1071
+ expect(res.status).toBe(401);
1072
+ const body = (await res.json()) as { error_description: string };
1073
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
1074
+ });
1075
+
1076
+ test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
1077
+ const { supervisor } = makeIdleSupervisor();
1078
+ // Public-iss token, loopback per-request issuer, NO knownIssuers wired →
1079
+ // the strict single-issuer fallback rejects (the pre-fix behavior the
1080
+ // non-HTTP install path relies on).
1081
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1082
+ const res = await handleStop(
1083
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1084
+ "vault",
1085
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
1086
+ );
1087
+ expect(res.status).toBe(401);
1088
+ });
1089
+ });
1090
+
970
1091
  describe("POST /api/modules/:short/restart", () => {
971
1092
  let h: Harness;
972
1093
  beforeEach(async () => {
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
63
63
  return signed.token;
64
64
  }
65
65
 
66
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
67
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
68
+ /** A foreign origin the hub never answers on (hub#516). */
69
+ const FOREIGN_ORIGIN = "https://evil.example.com";
70
+
71
+ /** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
72
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
73
+ const signed = await signAccessToken(h.db, {
74
+ sub: h.userId,
75
+ scopes,
76
+ audience: "operator",
77
+ clientId: "parachute-hub",
78
+ issuer: iss,
79
+ ttlSeconds: 3600,
80
+ });
81
+ recordTokenMint(h.db, {
82
+ jti: signed.jti,
83
+ createdVia: "operator_mint",
84
+ subject: "operator",
85
+ clientId: "parachute-hub",
86
+ scopes,
87
+ expiresAt: signed.expiresAt,
88
+ });
89
+ return signed.token;
90
+ }
91
+
66
92
  function writeManifest(path: string, services: unknown[]): void {
67
93
  writeFileSync(path, JSON.stringify({ services }));
68
94
  }
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
147
173
  expect(body.error).toBe("insufficient_scope");
148
174
  });
149
175
 
176
+ // hub#516: `parachute status` reads /api/modules on loopback presenting the
177
+ // operator token, whose `iss` is the PUBLIC origin after `expose`. The
178
+ // host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
179
+ // expose-state public ∪ env), not the single per-request loopback issuer.
180
+ test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
181
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
182
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
183
+ db: h.db,
184
+ issuer: ISSUER, // loopback per-request issuer
185
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
186
+ manifestPath: h.manifestPath,
187
+ fetchLatestVersion: async () => null,
188
+ });
189
+ expect(res.status).toBe(200);
190
+ });
191
+
192
+ test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
193
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
194
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
195
+ db: h.db,
196
+ issuer: ISSUER,
197
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
198
+ manifestPath: h.manifestPath,
199
+ fetchLatestVersion: async () => null,
200
+ });
201
+ expect(res.status).toBe(401);
202
+ const body = (await res.json()) as { error_description: string };
203
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
204
+ });
205
+
206
+ test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
207
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
208
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
209
+ db: h.db,
210
+ issuer: ISSUER, // no knownIssuers → falls back to [issuer]
211
+ manifestPath: h.manifestPath,
212
+ fetchLatestVersion: async () => null,
213
+ });
214
+ expect(res.status).toBe(401);
215
+ });
216
+
150
217
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
218
  // The v0.6 hot path: brand-new Render container, no services.json
152
219
  // yet. UI must render "install vault / scribe" cards even though
@@ -0,0 +1,218 @@
1
+ /**
2
+ * hub#516 — `validateHostAdminToken` accepts the operator / SPA host-admin
3
+ * token's `iss` against the SET of origins the hub answers on (loopback ∪
4
+ * expose-state public ∪ env/platform), not the single per-request issuer, so
5
+ * the loopback CLI works on an exposed box. OAuth-token validation
6
+ * (`validateAccessToken` with a pinned `expectedIssuer`) is NOT touched — see
7
+ * the strict-iss regression at the bottom of this file.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { validateHostAdminToken } from "../host-admin-token-validation.ts";
14
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
15
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
16
+ import { rotateSigningKey } from "../signing-keys.ts";
17
+ import { createUser } from "../users.ts";
18
+
19
+ const LOOPBACK = "http://127.0.0.1:1939";
20
+ const PUBLIC_TS = "https://parachute.taildf9ce2.ts.net";
21
+ const FOREIGN = "https://evil.example.com";
22
+
23
+ interface H {
24
+ db: ReturnType<typeof openHubDb>;
25
+ userId: string;
26
+ cleanup: () => void;
27
+ }
28
+
29
+ async function makeH(): Promise<H> {
30
+ const dir = mkdtempSync(join(tmpdir(), "phub-host-admin-tok-"));
31
+ const db = openHubDb(hubDbPath(dir));
32
+ rotateSigningKey(db);
33
+ const user = await createUser(db, "owner", "pw");
34
+ return {
35
+ db,
36
+ userId: user.id,
37
+ cleanup: () => {
38
+ db.close();
39
+ rmSync(dir, { recursive: true, force: true });
40
+ },
41
+ };
42
+ }
43
+
44
+ /** Mint an operator-shaped token (aud: "operator", host:admin) at `iss`. */
45
+ async function mintOperatorAt(h: H, iss: string): Promise<string> {
46
+ const signed = await signAccessToken(h.db, {
47
+ sub: h.userId,
48
+ scopes: ["parachute:host:admin", "parachute:host:auth"],
49
+ audience: "operator",
50
+ clientId: "parachute-hub",
51
+ issuer: iss,
52
+ ttlSeconds: 3600,
53
+ });
54
+ recordTokenMint(h.db, {
55
+ jti: signed.jti,
56
+ createdVia: "operator_mint",
57
+ subject: "operator",
58
+ clientId: "parachute-hub",
59
+ scopes: ["parachute:host:admin", "parachute:host:auth"],
60
+ expiresAt: signed.expiresAt,
61
+ });
62
+ return signed.token;
63
+ }
64
+
65
+ describe("validateHostAdminToken (hub#516)", () => {
66
+ // The live repro: operator token minted with the PUBLIC origin as `iss`,
67
+ // presented on a LOOPBACK request (known-origins set built from the loopback
68
+ // per-request issuer + the expose-state public origin) → ACCEPTED.
69
+ test("live repro: public-iss operator token accepted when public origin is in the known set", async () => {
70
+ const h = await makeH();
71
+ try {
72
+ const token = await mintOperatorAt(h, PUBLIC_TS);
73
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
74
+ const { payload } = await validateHostAdminToken(h.db, token, knownIssuers);
75
+ expect(payload.iss).toBe(PUBLIC_TS);
76
+ expect(payload.sub).toBe(h.userId);
77
+ } finally {
78
+ h.cleanup();
79
+ }
80
+ });
81
+
82
+ test("loopback-iss operator token, loopback known set → accepted (unchanged)", async () => {
83
+ const h = await makeH();
84
+ try {
85
+ const token = await mintOperatorAt(h, LOOPBACK);
86
+ const { payload } = await validateHostAdminToken(h.db, token, [
87
+ LOOPBACK,
88
+ "http://localhost:1939",
89
+ ]);
90
+ expect(payload.iss).toBe(LOOPBACK);
91
+ } finally {
92
+ h.cleanup();
93
+ }
94
+ });
95
+
96
+ test("FOREIGN iss → REJECTED (no widening to arbitrary issuers)", async () => {
97
+ const h = await makeH();
98
+ try {
99
+ // A token the hub itself signed but whose iss is NOT one of its known
100
+ // origins. The signature verifies, but the belt-and-suspenders iss ∈
101
+ // known-origins check must still reject it.
102
+ const token = await mintOperatorAt(h, FOREIGN);
103
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
104
+ await expect(validateHostAdminToken(h.db, token, knownIssuers)).rejects.toThrow(
105
+ /unexpected "iss" claim value/,
106
+ );
107
+ } finally {
108
+ h.cleanup();
109
+ }
110
+ });
111
+
112
+ test("empty known-origins set rejects every token (fails closed)", async () => {
113
+ const h = await makeH();
114
+ try {
115
+ const token = await mintOperatorAt(h, LOOPBACK);
116
+ await expect(validateHostAdminToken(h.db, token, [])).rejects.toThrow(
117
+ /unexpected "iss" claim value/,
118
+ );
119
+ } finally {
120
+ h.cleanup();
121
+ }
122
+ });
123
+
124
+ test("expose-state absent (loopback-only set): public-iss token rejected; loopback-iss accepted", async () => {
125
+ const h = await makeH();
126
+ try {
127
+ // Before `expose`, the known set is loopback-only — a public-iss token
128
+ // shouldn't exist yet, and if presented, it's not in the set → reject.
129
+ const publicTok = await mintOperatorAt(h, PUBLIC_TS);
130
+ const loopbackOnly = [LOOPBACK, "http://localhost:1939"];
131
+ await expect(validateHostAdminToken(h.db, publicTok, loopbackOnly)).rejects.toThrow(
132
+ /unexpected "iss" claim value/,
133
+ );
134
+
135
+ const loopbackTok = await mintOperatorAt(h, LOOPBACK);
136
+ const { payload } = await validateHostAdminToken(h.db, loopbackTok, loopbackOnly);
137
+ expect(payload.iss).toBe(LOOPBACK);
138
+ } finally {
139
+ h.cleanup();
140
+ }
141
+ });
142
+
143
+ test("signature still enforced: a token signed by an unknown key is rejected", async () => {
144
+ const h = await makeH();
145
+ try {
146
+ // Mint against a SECOND hub's key, then try to validate against the
147
+ // first hub's JWKS. The known-origins set includes the iss, but the
148
+ // signature check (step 1) must reject it before iss is even considered.
149
+ const other = await makeH();
150
+ try {
151
+ const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
152
+ await expect(validateHostAdminToken(h.db, foreignSigned, [PUBLIC_TS])).rejects.toThrow();
153
+ } finally {
154
+ other.cleanup();
155
+ }
156
+ } finally {
157
+ h.cleanup();
158
+ }
159
+ });
160
+
161
+ // Host-injection defense, made explicit. Simulate an attacker who got their
162
+ // own issuer into the known-issuers set via a forged Host header (the set is
163
+ // built from the per-request Host-derived issuer, so `iss ∈ knownIssuers`
164
+ // holds). They present a token signed by a DIFFERENT hub's key whose `iss`
165
+ // is exactly that injected origin. It is STILL REJECTED — the signature /
166
+ // JWKS check (step 1) fails before the `iss ∈ knownIssuers` check (step 2)
167
+ // is ever reached. This pins that known-issuers acceptance can never bypass
168
+ // the signature gate: membership in the set is necessary but not sufficient.
169
+ test("Host-injection: foreign-signed token with iss IN knownIssuers is STILL rejected (signature gate is first)", async () => {
170
+ const h = await makeH();
171
+ try {
172
+ const other = await makeH();
173
+ try {
174
+ // Token minted + signed by the OTHER hub at PUBLIC_TS.
175
+ const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
176
+ // The attacker's iss IS in our known set (e.g. injected via Host), yet
177
+ // validation against OUR JWKS must reject it on the signature check.
178
+ const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
179
+ expect(knownIssuers).toContain(PUBLIC_TS); // precondition: iss ∈ knownIssuers
180
+ await expect(validateHostAdminToken(h.db, foreignSigned, knownIssuers)).rejects.toThrow();
181
+ } finally {
182
+ other.cleanup();
183
+ }
184
+ } finally {
185
+ h.cleanup();
186
+ }
187
+ });
188
+
189
+ // Pin the invariant: OAuth/access-token validation stays STRICT per-request
190
+ // issuer. The relaxation lives only in validateHostAdminToken; the core
191
+ // primitive validateAccessToken(db, token, expectedIssuer) still rejects a
192
+ // mismatched iss. (Mirrors jwt-sign.test.ts's defense-in-depth test; kept
193
+ // here so the hub#516 guard travels with the relaxation.)
194
+ test("OAuth-token validation UNCHANGED: validateAccessToken pins iss strictly", async () => {
195
+ const h = await makeH();
196
+ try {
197
+ // A vault-aud OAuth-shaped token minted at the public origin.
198
+ const signed = await signAccessToken(h.db, {
199
+ sub: h.userId,
200
+ scopes: ["vault:read"],
201
+ audience: "vault.default",
202
+ clientId: "some-mcp-client",
203
+ issuer: PUBLIC_TS,
204
+ ttlSeconds: 3600,
205
+ });
206
+ // Strict per-request validation against a DIFFERENT (loopback) issuer
207
+ // still rejects — the relaxation must NOT leak to this path.
208
+ await expect(validateAccessToken(h.db, signed.token, LOOPBACK)).rejects.toThrow(
209
+ /unexpected "iss" claim value/,
210
+ );
211
+ // And it accepts when the issuer matches (sanity).
212
+ const { payload } = await validateAccessToken(h.db, signed.token, PUBLIC_TS);
213
+ expect(payload.aud).toBe("vault.default");
214
+ } finally {
215
+ h.cleanup();
216
+ }
217
+ });
218
+ });
@@ -429,10 +429,13 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
429
429
  expect(() => hubUnit(f.deps)).toThrow(/'bun' not found on PATH/);
430
430
  });
431
431
 
432
- test("env carries the 4 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
432
+ test("env carries the 5 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
433
433
  const f = fakeDeps({ platform: "linux" });
434
434
  const unit = hubUnit(f.deps);
435
435
  expect(unit.env).toEqual({
436
+ // Forced loopback (security): a self-hosted supervised hub must NOT inherit
437
+ // serve.ts's container-first 0.0.0.0 default and bare-serve all-interfaces.
438
+ PARACHUTE_BIND_HOST: "127.0.0.1",
436
439
  PARACHUTE_HOME: "/home/op/.parachute",
437
440
  PORT: "1939",
438
441
  PATH: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
@@ -441,6 +444,17 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
441
444
  expect(unit.env.PARACHUTE_HUB_ORIGIN).toBeUndefined();
442
445
  });
443
446
 
447
+ test("env forces PARACHUTE_BIND_HOST=127.0.0.1 (loopback trust model — never 0.0.0.0)", () => {
448
+ const f = fakeDeps({ platform: "linux" });
449
+ const unit = hubUnit(f.deps);
450
+ // The whole point of the fix: the supervised hub unit binds loopback, NOT
451
+ // the serve.ts container-first 0.0.0.0 default. Covers both init + migrate
452
+ // (both route through buildHubManagedUnit) and both platforms (the env is
453
+ // platform-agnostic; systemd/launchd render shapes are asserted below).
454
+ expect(unit.env.PARACHUTE_BIND_HOST).toBe("127.0.0.1");
455
+ expect(unit.env.PARACHUTE_BIND_HOST).not.toBe("0.0.0.0");
456
+ });
457
+
444
458
  test("PARACHUTE_HOME is the captured param, NOT the default (§4.2)", () => {
445
459
  const f = fakeDeps({ platform: "linux" });
446
460
  const unit = buildHubManagedUnit({
@@ -468,11 +482,14 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
468
482
  expect(unit.env.PORT).toBe("2939");
469
483
  });
470
484
 
471
- test("rendered systemd SYSTEM unit: 4 Environment= vars, User= present, StartLimit present", () => {
485
+ test("rendered systemd SYSTEM unit: 5 Environment= vars, User= present, StartLimit present", () => {
472
486
  const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "op" });
473
487
  const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: true, userName: "op" });
474
488
  expect(unit).toContain("Description=Parachute hub (serve + supervisor)");
475
489
  expect(unit).toContain("User=op");
490
+ // Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
491
+ expect(unit).toContain("Environment=PARACHUTE_BIND_HOST=127.0.0.1");
492
+ expect(unit).not.toContain("PARACHUTE_BIND_HOST=0.0.0.0");
476
493
  expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
477
494
  expect(unit).toContain("Environment=PORT=1939");
478
495
  expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin");
@@ -494,7 +511,7 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
494
511
  expect(unit).toContain("WantedBy=default.target");
495
512
  });
496
513
 
497
- test("rendered launchd plist: EnvironmentVariables dict (4 vars) + ThrottleInterval + abs ProgramArguments", () => {
514
+ test("rendered launchd plist: EnvironmentVariables dict (5 vars) + ThrottleInterval + abs ProgramArguments", () => {
498
515
  const f = fakeDeps({ platform: "darwin" });
499
516
  const plist = renderManagedLaunchdPlist(hubUnit(f.deps));
500
517
  expect(plist).toContain("<key>Label</key>\n <string>computer.parachute.hub</string>");
@@ -502,6 +519,9 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
502
519
  expect(plist).toContain("<string>/home/op/parachute-hub/src/cli.ts</string>");
503
520
  expect(plist).toContain("<string>serve</string>");
504
521
  expect(plist).toContain("<key>EnvironmentVariables</key>");
522
+ // Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
523
+ expect(plist).toContain("<key>PARACHUTE_BIND_HOST</key>\n <string>127.0.0.1</string>");
524
+ expect(plist).not.toContain("<string>0.0.0.0</string>");
505
525
  expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
506
526
  expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
507
527
  expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
@@ -122,6 +122,12 @@ function makeFakeCutover(over: Partial<CutoverDeps> = {}): FakeCutover {
122
122
  messages: ["started unit"],
123
123
  };
124
124
  },
125
+ // Hermetic default: the stale-unit disable is a no-op (no real
126
+ // systemctl/launchctl). Tests that exercise #522 override this to trace + act.
127
+ disableStaleModuleUnits: () => {
128
+ trace.push("disableStaleUnits");
129
+ return { actions: [] };
130
+ },
125
131
  ...over,
126
132
  };
127
133
  // Expose the world via closure for tests that want to manipulate it.
@@ -184,6 +190,43 @@ describe("cutoverToSupervised — happy path (§7.1)", () => {
184
190
  }
185
191
  });
186
192
 
193
+ test("#522: stale-unit disable runs in the STOP phase — after detached stop, before unit start", async () => {
194
+ const h = makeHarness();
195
+ try {
196
+ seedManifest(h.manifestPath, [{ name: "vault", port: 1940 }]);
197
+ const fc = makeFakeCutover();
198
+ const w = getWorld(fc.deps);
199
+ w.listening.add(1939);
200
+ w.listening.add(1940);
201
+ w.alivePids.add(5555);
202
+ writePid("vault", 5555, h.configDir);
203
+ const baseKill = fc.deps.kill;
204
+ fc.deps.kill = (pid, signal) => {
205
+ baseKill?.(pid, signal);
206
+ if (pid === 5555) getWorld(fc.deps).listening.delete(1940);
207
+ };
208
+ const result = await cutoverToSupervised({
209
+ configDir: h.configDir,
210
+ manifestPath: h.manifestPath,
211
+ deps: fc.deps,
212
+ log: () => {},
213
+ pollMs: 0,
214
+ });
215
+ expect(result.outcome).toBe("migrated");
216
+ const stopIdx = fc.trace.indexOf("stopHub");
217
+ const disableIdx = fc.trace.indexOf("disableStaleUnits");
218
+ const startIdx = fc.trace.indexOf("startUnit");
219
+ // The disable runs AFTER the detached stop and BEFORE the unit start, so a
220
+ // KeepAlive/Restart=always unit can't re-grab the port between freeing it
221
+ // and the supervised module binding it.
222
+ expect(disableIdx).toBeGreaterThanOrEqual(0);
223
+ expect(stopIdx).toBeLessThan(disableIdx);
224
+ expect(disableIdx).toBeLessThan(startIdx);
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
187
230
  test("verify-ports-free runs before start (start never races a held port)", async () => {
188
231
  const h = makeHarness();
189
232
  try {
@@ -443,6 +486,7 @@ describe("cutoverToSupervised — fail-safe recovery states", () => {
443
486
  describe("teardownHubUnit (§7.4)", () => {
444
487
  test("removes the hub unit (idempotent success path)", () => {
445
488
  let removeArgs: { launchdLabel: string; systemdUnitName: string } | undefined;
489
+ let staleCalled = false;
446
490
  const log: string[] = [];
447
491
  const res = teardownHubUnit({
448
492
  log: (l) => log.push(l),
@@ -450,21 +494,36 @@ describe("teardownHubUnit (§7.4)", () => {
450
494
  removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
451
495
  return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
452
496
  },
497
+ // Hermetic stub — no real systemctl/launchctl.
498
+ disableStaleModuleUnits: () => {
499
+ staleCalled = true;
500
+ return { actions: [] };
501
+ },
453
502
  });
454
503
  expect(res.removed).toBe(true);
455
504
  expect(removeArgs?.launchdLabel).toBe("computer.parachute.hub");
456
505
  expect(removeArgs?.systemdUnitName).toBe("parachute-hub.service");
506
+ // #522: teardown also runs the stale-per-module-autostart disable.
507
+ expect(staleCalled).toBe(true);
457
508
  // Surfaces the fallback hint.
458
509
  expect(log.join("\n")).toContain("parachute serve");
459
510
  });
460
511
 
461
- test("no unit installed → no-op, friendly message", () => {
512
+ test("no unit installed → no-op, friendly message (still runs the stale-unit disable)", () => {
513
+ let staleCalled = false;
462
514
  const log: string[] = [];
463
515
  const res = teardownHubUnit({
464
516
  log: (l) => log.push(l),
465
517
  remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
518
+ disableStaleModuleUnits: () => {
519
+ staleCalled = true;
520
+ return { actions: [] };
521
+ },
466
522
  });
467
523
  expect(res.removed).toBe(false);
524
+ // #522: a leftover module autostart must be cleaned even when the hub unit was
525
+ // never installed (a partial / never-migrated box rolling back).
526
+ expect(staleCalled).toBe(true);
468
527
  expect(log.join("\n")).toContain("nothing to tear down");
469
528
  });
470
529
  });