@openparachute/hub 0.7.7-rc.9 → 0.7.7
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/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +210 -38
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +17 -26
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +22 -92
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +8 -13
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +9 -7
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +72 -27
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +0 -2
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +0 -1
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +18 -47
- package/src/services-manifest.ts +9 -42
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe
|
|
69
|
+
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe admin scopes (#213)", () => {
|
|
70
70
|
// OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
|
|
71
71
|
// alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
|
|
72
72
|
// parachute:host:install/start/expose/auth/vault scopes to the admin
|
|
@@ -81,7 +81,6 @@ describe("mintOperatorToken", () => {
|
|
|
81
81
|
"parachute:host:vault",
|
|
82
82
|
"vault:admin",
|
|
83
83
|
"scribe:admin",
|
|
84
|
-
"agent:send",
|
|
85
84
|
]);
|
|
86
85
|
});
|
|
87
86
|
});
|
|
@@ -44,21 +44,18 @@ describe("assignPort (pure)", () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test("walks the unassigned reservation range when canonical is occupied", () => {
|
|
47
|
-
// 1940 is taken.
|
|
48
|
-
// P5, status "assigned") so the walker skips it; the first RESERVED
|
|
49
|
-
// (walkable) slot is 1945.
|
|
47
|
+
// 1940 is taken. Agent retirement released 1941 as the first walkable slot.
|
|
50
48
|
const result = assignPort(1940, [1940]);
|
|
51
|
-
expect(result.port).toBe(
|
|
49
|
+
expect(result.port).toBe(1941);
|
|
52
50
|
expect(result.source).toBe("fallback-in-range");
|
|
53
51
|
expect(result.warning).toMatch(/canonical port 1940 is in use/);
|
|
54
|
-
expect(result.warning).toMatch(/
|
|
52
|
+
expect(result.warning).toMatch(/1941/);
|
|
55
53
|
});
|
|
56
54
|
|
|
57
55
|
test("skips reservations that are also occupied", () => {
|
|
58
|
-
// Canonical 1940
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
const result = assignPort(1940, [1940, 1944, 1945, 1946]);
|
|
56
|
+
// Canonical 1940 and released slot 1941 are occupied. Assigned slots are
|
|
57
|
+
// skipped; 1945 is reserved-but-occupied; 1947 is reserved-and-free.
|
|
58
|
+
const result = assignPort(1940, [1940, 1941, 1945]);
|
|
62
59
|
expect(result.port).toBe(1947);
|
|
63
60
|
expect(result.source).toBe("fallback-in-range");
|
|
64
61
|
});
|
|
@@ -83,23 +80,19 @@ describe("assignPort (pure)", () => {
|
|
|
83
80
|
});
|
|
84
81
|
|
|
85
82
|
test("third-party (no canonical slot) jumps straight to the reservation range", () => {
|
|
86
|
-
//
|
|
87
|
-
// the first RESERVED (walkable) slot is 1945.
|
|
83
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
88
84
|
const result = assignPort(undefined, []);
|
|
89
|
-
expect(result.port).toBe(
|
|
85
|
+
expect(result.port).toBe(1941);
|
|
90
86
|
expect(result.source).toBe("fallback-in-range");
|
|
91
87
|
expect(result.warning).toMatch(/no canonical slot/);
|
|
92
|
-
expect(result.warning).toMatch(/
|
|
88
|
+
expect(result.warning).toMatch(/1941/);
|
|
93
89
|
});
|
|
94
90
|
|
|
95
91
|
test("third-party with reservations occupied walks further in the range", () => {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// lands on the next reserved-and-free port — 1947.
|
|
101
|
-
const result = assignPort(undefined, [1944, 1945]);
|
|
102
|
-
expect(result.port).toBe(1947);
|
|
92
|
+
// With released 1941 occupied, the walker skips assigned 1942–1944 and
|
|
93
|
+
// lands on the next reserved-and-free port, 1945.
|
|
94
|
+
const result = assignPort(undefined, [1941]);
|
|
95
|
+
expect(result.port).toBe(1945);
|
|
103
96
|
expect(result.source).toBe("fallback-in-range");
|
|
104
97
|
});
|
|
105
98
|
});
|
|
@@ -170,9 +163,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
|
|
|
170
163
|
canonical: 1940,
|
|
171
164
|
occupied: [1940],
|
|
172
165
|
});
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
expect(result.port).toBe(1945);
|
|
166
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
167
|
+
expect(result.port).toBe(1941);
|
|
176
168
|
expect(result.source).toBe("fallback-in-range");
|
|
177
169
|
expect(result.warning).toMatch(/canonical port 1940 is in use/);
|
|
178
170
|
// .env stays bit-for-bit identical.
|
|
@@ -189,9 +181,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
|
|
|
189
181
|
const result = assignServicePort({
|
|
190
182
|
occupied: [],
|
|
191
183
|
});
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
expect(result.port).toBe(1945);
|
|
184
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
185
|
+
expect(result.port).toBe(1941);
|
|
195
186
|
expect(result.source).toBe("fallback-in-range");
|
|
196
187
|
expect(existsSync(envPath)).toBe(false);
|
|
197
188
|
} finally {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `src/root-serve.ts` — serving the Parachute app AT the origin root
|
|
3
|
+
* (`root_mode = serve-app`).
|
|
4
|
+
*
|
|
5
|
+
* Covers `serveAppAtRoot` (the static file-or-SPA-shell decision, Accept-gated,
|
|
6
|
+
* reserved-prefix-guarded, traversal-guarded) and `makeAppDistResolver` (success
|
|
7
|
+
* memoization + failure NON-caching for dynamic recovery).
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { makeAppDistResolver, serveAppAtRoot } from "../root-serve.ts";
|
|
14
|
+
|
|
15
|
+
let dist: string;
|
|
16
|
+
let root: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
root = mkdtempSync(join(tmpdir(), "phub-root-serve-"));
|
|
20
|
+
dist = join(root, "dist");
|
|
21
|
+
mkdirSync(join(dist, "assets"), { recursive: true });
|
|
22
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>App</title>");
|
|
23
|
+
writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('app')");
|
|
24
|
+
writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
|
|
25
|
+
writeFileSync(join(dist, "sw.js"), "self.addEventListener('install',()=>{})");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function get(pathname: string, accept?: string): Request {
|
|
33
|
+
return new Request(`http://localhost${pathname}`, {
|
|
34
|
+
method: "GET",
|
|
35
|
+
headers: accept ? { accept } : {},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("serveAppAtRoot", () => {
|
|
40
|
+
test("serves index.html at `/`", async () => {
|
|
41
|
+
const res = serveAppAtRoot(dist, get("/", "text/html"), "/");
|
|
42
|
+
expect(res).not.toBeNull();
|
|
43
|
+
expect(res?.headers.get("content-type")).toContain("text/html");
|
|
44
|
+
expect(await res?.text()).toContain("<title>App</title>");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("serves an existing asset with an inferred content-type", async () => {
|
|
48
|
+
const res = serveAppAtRoot(dist, get("/assets/index-abc.js", "*/*"), "/assets/index-abc.js");
|
|
49
|
+
expect(res).not.toBeNull();
|
|
50
|
+
expect(res?.headers.get("content-type") ?? "").toMatch(/javascript/);
|
|
51
|
+
expect(await res?.text()).toContain("console.log");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("serves the PWA manifest with the application/manifest+json override", () => {
|
|
55
|
+
const res = serveAppAtRoot(dist, get("/manifest.webmanifest", "*/*"), "/manifest.webmanifest");
|
|
56
|
+
expect(res).not.toBeNull();
|
|
57
|
+
expect(res?.headers.get("content-type")).toBe("application/manifest+json");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("serves the service worker as a real file (not the SPA shell)", async () => {
|
|
61
|
+
const res = serveAppAtRoot(dist, get("/sw.js", "*/*"), "/sw.js");
|
|
62
|
+
expect(res).not.toBeNull();
|
|
63
|
+
expect(await res?.text()).toContain("addEventListener");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("SPA fallback: an unclaimed HTML deep link gets index.html", async () => {
|
|
67
|
+
const res = serveAppAtRoot(dist, get("/some/app/route", "text/html"), "/some/app/route");
|
|
68
|
+
expect(res).not.toBeNull();
|
|
69
|
+
expect(await res?.text()).toContain("<title>App</title>");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("non-HTML unclaimed request → null (branded 404 tail)", () => {
|
|
73
|
+
expect(serveAppAtRoot(dist, get("/nope.json", "application/json"), "/nope.json")).toBeNull();
|
|
74
|
+
// A missing asset fetched with Accept: */* is not an HTML navigation → null.
|
|
75
|
+
expect(serveAppAtRoot(dist, get("/assets/missing.js", "*/*"), "/assets/missing.js")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("non-GET → null (a non-GET unclaimed path keeps its default)", () => {
|
|
79
|
+
const post = new Request("http://localhost/", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { accept: "text/html" },
|
|
82
|
+
});
|
|
83
|
+
expect(serveAppAtRoot(dist, post, "/")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reserved hub/protocol prefixes keep the branded 404 even for HTML nav", () => {
|
|
87
|
+
for (const p of ["/api/bogus", "/oauth/typo", "/.well-known/nope"]) {
|
|
88
|
+
expect(serveAppAtRoot(dist, get(p, "text/html"), p)).toBeNull();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("path traversal cannot escape dist", () => {
|
|
93
|
+
// A crafted encoded traversal joins outside dist → falls through (null).
|
|
94
|
+
const p = "/assets/..%2f..%2f..%2fetc%2fpasswd";
|
|
95
|
+
expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a resolved dist whose index.html vanished → null (no broken shell)", () => {
|
|
99
|
+
rmSync(join(dist, "index.html"));
|
|
100
|
+
expect(serveAppAtRoot(dist, get("/", "text/html"), "/")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("malformed percent-encoding → null (no URIError thrown → branded 404, not 500)", () => {
|
|
104
|
+
// decodeURIComponent throws URIError on a bad escape; the guard must swallow
|
|
105
|
+
// it and fall through so the dispatch never 500s on a garbage asset URL.
|
|
106
|
+
for (const p of ["/assets/%ZZ", "/foo%", "/%E0%A4%A", "/bar%2"]) {
|
|
107
|
+
expect(() => serveAppAtRoot(dist, get(p, "*/*"), p)).not.toThrow();
|
|
108
|
+
expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
|
|
109
|
+
}
|
|
110
|
+
// Even an HTML navigation with bad encoding falls through (redirect-mode
|
|
111
|
+
// parity) rather than shelling.
|
|
112
|
+
expect(serveAppAtRoot(dist, get("/foo%", "text/html"), "/foo%")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("makeAppDistResolver", () => {
|
|
117
|
+
test("returns the resolved dist and memoizes a success", () => {
|
|
118
|
+
let calls = 0;
|
|
119
|
+
const resolve = makeAppDistResolver(() => {
|
|
120
|
+
calls++;
|
|
121
|
+
return dist;
|
|
122
|
+
});
|
|
123
|
+
expect(resolve()).toBe(dist);
|
|
124
|
+
expect(resolve()).toBe(dist);
|
|
125
|
+
expect(calls).toBe(1); // success cached — only resolved once
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("does NOT cache a failure — recovers once the app is installed", () => {
|
|
129
|
+
let installed = false;
|
|
130
|
+
const resolve = makeAppDistResolver(() => {
|
|
131
|
+
if (!installed) throw new Error("not installed");
|
|
132
|
+
return dist;
|
|
133
|
+
});
|
|
134
|
+
expect(resolve()).toBeNull(); // not installed yet
|
|
135
|
+
expect(resolve()).toBeNull(); // still probing, still null
|
|
136
|
+
installed = true;
|
|
137
|
+
expect(resolve()).toBe(dist); // picked up without a restart
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -19,7 +19,6 @@ describe("SCOPE_EXPLANATIONS", () => {
|
|
|
19
19
|
"vault:admin",
|
|
20
20
|
"scribe:transcribe",
|
|
21
21
|
"scribe:admin",
|
|
22
|
-
"agent:send",
|
|
23
22
|
"hub:admin",
|
|
24
23
|
"parachute:host:admin",
|
|
25
24
|
// Account scopes (Parachute App campaign, Phase 2).
|
|
@@ -187,7 +186,6 @@ describe("isRequestableScope", () => {
|
|
|
187
186
|
test("true for non-admin first-party scopes", () => {
|
|
188
187
|
expect(isRequestableScope("vault:read")).toBe(true);
|
|
189
188
|
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
190
|
-
expect(isRequestableScope("agent:send")).toBe(true);
|
|
191
189
|
expect(isRequestableScope("scribe:transcribe")).toBe(true);
|
|
192
190
|
});
|
|
193
191
|
|
|
@@ -395,46 +395,39 @@ describe("bootSupervisedModules", () => {
|
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
// the reverse-proxy (which reads services.json) routes correctly.
|
|
403
|
-
describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
398
|
+
// A transiently wrong services.json port for a fixed-port first-party module can
|
|
399
|
+
// self-perpetuate through supervisor env, probes, and proxy routing. The boot path
|
|
400
|
+
// snaps supported modules back to canonical and persists the fix.
|
|
401
|
+
describe("reconcilePortToCanonical", () => {
|
|
404
402
|
let h: Harness;
|
|
405
403
|
beforeEach(() => {
|
|
406
404
|
h = makeHarness();
|
|
407
405
|
});
|
|
408
406
|
afterEach(() => h.cleanup());
|
|
409
407
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const DRIFTED_CHANNEL: ServiceEntry = {
|
|
413
|
-
name: "parachute-agent",
|
|
408
|
+
const DRIFTED_SCRIBE: ServiceEntry = {
|
|
409
|
+
name: "parachute-scribe",
|
|
414
410
|
port: 19415,
|
|
415
|
-
paths: ["/
|
|
411
|
+
paths: ["/scribe"],
|
|
416
412
|
health: "/health",
|
|
417
413
|
version: "0.1.0",
|
|
418
414
|
};
|
|
419
415
|
|
|
420
416
|
test("snaps a drifted fixed-port row back to canonical + persists it", () => {
|
|
421
|
-
writeManifest({ services: [
|
|
417
|
+
writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
|
|
422
418
|
const logs: string[] = [];
|
|
423
419
|
|
|
424
|
-
const reconciled = reconcilePortToCanonical(
|
|
420
|
+
const reconciled = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) =>
|
|
425
421
|
logs.push(l),
|
|
426
422
|
);
|
|
427
423
|
|
|
428
|
-
|
|
429
|
-
expect(reconciled.port).toBe(1941);
|
|
430
|
-
// And it's PERSISTED — the proxy reads services.json, so the row itself must
|
|
431
|
-
// now point at 1941 or `/agent/*` keeps routing to the dead 19415.
|
|
424
|
+
expect(reconciled.port).toBe(1943);
|
|
432
425
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
433
|
-
(s) => s.name === "parachute-
|
|
426
|
+
(s) => s.name === "parachute-scribe",
|
|
434
427
|
);
|
|
435
|
-
expect(onDisk?.port).toBe(
|
|
428
|
+
expect(onDisk?.port).toBe(1943);
|
|
436
429
|
expect(
|
|
437
|
-
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("
|
|
430
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
|
|
438
431
|
).toBe(true);
|
|
439
432
|
});
|
|
440
433
|
|
|
@@ -460,31 +453,30 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
|
460
453
|
});
|
|
461
454
|
|
|
462
455
|
test("does NOT steal the canonical port when another row already holds it", () => {
|
|
463
|
-
// Another row
|
|
464
|
-
//
|
|
465
|
-
// drift; the supervisor's squatter detection surfaces it.
|
|
456
|
+
// Another row occupies Scribe's canonical 1943, so reconciliation must not
|
|
457
|
+
// steal it; leave the drift for squatter diagnostics.
|
|
466
458
|
const squatter: ServiceEntry = {
|
|
467
459
|
name: "parachute-vault",
|
|
468
|
-
port:
|
|
460
|
+
port: 1943,
|
|
469
461
|
paths: ["/vault/default"],
|
|
470
462
|
health: "/vault/default/health",
|
|
471
463
|
version: "0.4.5",
|
|
472
464
|
};
|
|
473
|
-
writeManifest({ services: [squatter,
|
|
465
|
+
writeManifest({ services: [squatter, DRIFTED_SCRIBE] }, h.manifestPath);
|
|
474
466
|
const logs: string[] = [];
|
|
475
467
|
|
|
476
|
-
const out = reconcilePortToCanonical(
|
|
468
|
+
const out = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) => logs.push(l));
|
|
477
469
|
|
|
478
470
|
expect(out.port).toBe(19415); // unchanged
|
|
479
471
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
480
|
-
(s) => s.name === "parachute-
|
|
472
|
+
(s) => s.name === "parachute-scribe",
|
|
481
473
|
);
|
|
482
474
|
expect(onDisk?.port).toBe(19415); // not rewritten
|
|
483
475
|
expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
|
|
484
476
|
});
|
|
485
477
|
|
|
486
|
-
test("boot path injects
|
|
487
|
-
writeManifest({ services: [
|
|
478
|
+
test("boot path injects and persists the supported module's canonical port", async () => {
|
|
479
|
+
writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
|
|
488
480
|
const recorder = makeRecorder();
|
|
489
481
|
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
490
482
|
|
|
@@ -493,15 +485,12 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
|
493
485
|
configDir: h.dir,
|
|
494
486
|
});
|
|
495
487
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
expect(recorder.calls[0]?.short).toBe("agent");
|
|
499
|
-
expect(recorder.calls[0]?.env?.PORT).toBe("1941");
|
|
500
|
-
// services.json row is reconciled → proxy routes /agent/* to 1941.
|
|
488
|
+
expect(recorder.calls[0]?.short).toBe("scribe");
|
|
489
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1943");
|
|
501
490
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
502
|
-
(s) => s.name === "parachute-
|
|
491
|
+
(s) => s.name === "parachute-scribe",
|
|
503
492
|
);
|
|
504
|
-
expect(onDisk?.port).toBe(
|
|
493
|
+
expect(onDisk?.port).toBe(1943);
|
|
505
494
|
});
|
|
506
495
|
|
|
507
496
|
test("boot path leaves a non-drifted vault row's port untouched", async () => {
|
|
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
FIRST_PARTY_FALLBACKS,
|
|
4
4
|
KNOWN_MODULES,
|
|
5
|
+
PORT_RESERVATIONS,
|
|
6
|
+
RETIRED_MODULES,
|
|
5
7
|
discoverableShorts,
|
|
6
8
|
findServiceByShort,
|
|
7
9
|
focusForShort,
|
|
@@ -25,20 +27,18 @@ describe("discoverableShorts", () => {
|
|
|
25
27
|
expect(shorts.length).toBe(new Set(shorts).size);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
test("includes
|
|
30
|
+
test("includes the supported module set and excludes retired Agent", () => {
|
|
29
31
|
const shorts = discoverableShorts();
|
|
30
|
-
for (const s of ["vault", "scribe", "surface", "
|
|
32
|
+
for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
|
|
31
33
|
expect(shorts).toContain(s);
|
|
32
34
|
}
|
|
35
|
+
expect(shorts).not.toContain("agent");
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
test("FIRST_PARTY_FALLBACKS shorts lead KNOWN_MODULES shorts (registry order)", () => {
|
|
36
39
|
const shorts = discoverableShorts();
|
|
37
|
-
// notes (
|
|
38
|
-
// boundary D3) appears before vault (KNOWN_MODULES) in the union.
|
|
40
|
+
// notes (a FALLBACK) appears before vault (KNOWN_MODULES) in the union.
|
|
39
41
|
expect(shorts.indexOf("notes")).toBeLessThan(shorts.indexOf("vault"));
|
|
40
|
-
// agent rides in KNOWN_MODULES now but is still discoverable.
|
|
41
|
-
expect(shorts).toContain("agent");
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
|
|
@@ -106,9 +106,30 @@ describe("runner registry removal (2026-07-01)", () => {
|
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
describe("Agent module retirement (2026-07-15)", () => {
|
|
110
|
+
test("is no longer known, discoverable, or resolvable", () => {
|
|
111
|
+
expect(isKnownModuleShort("agent")).toBe(false);
|
|
112
|
+
expect(discoverableShorts()).not.toContain("agent");
|
|
113
|
+
expect("agent" in KNOWN_MODULES).toBe(false);
|
|
114
|
+
expect(shortNameForManifest("parachute-agent")).toBeUndefined();
|
|
115
|
+
expect(shortNameForManifest("parachute-channel")).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("retires every historical row name and releases port 1941", () => {
|
|
119
|
+
expect(RETIRED_MODULES.agent).toBeDefined();
|
|
120
|
+
expect(RETIRED_MODULES["parachute-agent"]).toBeDefined();
|
|
121
|
+
expect(RETIRED_MODULES["parachute-channel"]).toBeDefined();
|
|
122
|
+
expect(PORT_RESERVATIONS.find((entry) => entry.port === 1941)).toEqual({
|
|
123
|
+
port: 1941,
|
|
124
|
+
name: "unassigned",
|
|
125
|
+
status: "reserved",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
109
130
|
describe("isKnownModuleShort", () => {
|
|
110
131
|
test("true for every known module (the install/config gate)", () => {
|
|
111
|
-
for (const s of ["vault", "scribe", "surface", "
|
|
132
|
+
for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
|
|
112
133
|
expect(isKnownModuleShort(s)).toBe(true);
|
|
113
134
|
}
|
|
114
135
|
});
|
|
@@ -119,12 +140,6 @@ describe("isKnownModuleShort", () => {
|
|
|
119
140
|
});
|
|
120
141
|
});
|
|
121
142
|
|
|
122
|
-
// Regression: services.json rows carry the MANIFEST name (`parachute-agent`),
|
|
123
|
-
// not the bare short (`agent`). The connection/channels wiring used to do
|
|
124
|
-
// `services.find((s) => s.name === "agent")`, which never matched the on-disk
|
|
125
|
-
// row → agentOrigin null → a spurious "agent module is not installed" when
|
|
126
|
-
// linking a vault-backed channel. findServiceByShort resolves through the
|
|
127
|
-
// short↔manifest map so the lookup hits the real row.
|
|
128
143
|
describe("findServiceByShort", () => {
|
|
129
144
|
const services = [
|
|
130
145
|
{ name: "parachute-vault-default", port: 1940 },
|
|
@@ -132,29 +147,9 @@ describe("findServiceByShort", () => {
|
|
|
132
147
|
{ name: "parachute-scribe", port: 1943 },
|
|
133
148
|
];
|
|
134
149
|
|
|
135
|
-
test("
|
|
136
|
-
const found = findServiceByShort(services, "agent");
|
|
137
|
-
expect(found?.name).toBe("parachute-agent");
|
|
138
|
-
expect(found?.port).toBe(1941);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("the naive `name === short` comparison would have missed it (the bug)", () => {
|
|
142
|
-
// The exact pre-fix predicate: a bare short never matches a manifest-named row.
|
|
143
|
-
expect(services.find((s) => s.name === "agent")).toBeUndefined();
|
|
144
|
-
// The fix finds it.
|
|
145
|
-
expect(findServiceByShort(services, "agent")).toBeDefined();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("resolves a legacy parachute-channel row to short `agent` (rename back-compat)", () => {
|
|
149
|
-
// Un-upgraded operators carry a `parachute-channel` row; the
|
|
150
|
-
// LEGACY_MANIFEST_ALIASES fallback keeps it routing to the agent module
|
|
151
|
-
// until the daemon re-registers under `parachute-agent`.
|
|
152
|
-
const legacy = [{ name: "parachute-channel", port: 1941 }];
|
|
153
|
-
expect(findServiceByShort(legacy, "agent")?.port).toBe(1941);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("resolves scribe too, and returns undefined for an absent module", () => {
|
|
150
|
+
test("resolves supported modules and not retired Agent rows", () => {
|
|
157
151
|
expect(findServiceByShort(services, "scribe")?.port).toBe(1943);
|
|
152
|
+
expect(findServiceByShort(services, "agent")).toBeUndefined();
|
|
158
153
|
expect(findServiceByShort(services, "runner")).toBeUndefined();
|
|
159
154
|
});
|
|
160
155
|
});
|
|
@@ -404,9 +404,9 @@ describe("services-manifest", () => {
|
|
|
404
404
|
slug: {
|
|
405
405
|
displayName: "S",
|
|
406
406
|
path: "/app/s",
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
407
|
+
// Deliberately write the pre-F legacy alias to pin the
|
|
408
|
+
// normalization boundary; the schema accepts it on read.
|
|
409
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy fixture value
|
|
410
410
|
status: "pending-oauth" as any,
|
|
411
411
|
},
|
|
412
412
|
},
|
|
@@ -872,20 +872,7 @@ describe("services-manifest", () => {
|
|
|
872
872
|
});
|
|
873
873
|
});
|
|
874
874
|
|
|
875
|
-
describe("
|
|
876
|
-
// Paraclaw was renamed to parachute-agent across the ecosystem (npm
|
|
877
|
-
// package, mount path, short name). The migration was a transitional
|
|
878
|
-
// read-time rewrite that aliased legacy `name: "claw"` rows to
|
|
879
|
-
// `name: "agent"` so operators on the old shape kept routing.
|
|
880
|
-
//
|
|
881
|
-
// History: parachute-agent (the Claude-in-containers module) was retired
|
|
882
|
-
// 2026-05-20 (hub#334 added `agent` to RETIRED_MODULES), which briefly made
|
|
883
|
-
// this a one-step retirement path (claw → agent → GC'd). The 2026-06-17
|
|
884
|
-
// channel→agent rename RE-ASSIGNED `agent`/`parachute-agent` to the renamed
|
|
885
|
-
// channel module, so those names left RETIRED_MODULES — `agent` is a live
|
|
886
|
-
// module again. The claw → agent rewrite still runs; the migrated row now
|
|
887
|
-
// PERSISTS (it routes to the live agent module). The tests below assert the
|
|
888
|
-
// rewrite + the persistence.
|
|
875
|
+
describe("historical Agent row retirement", () => {
|
|
889
876
|
const claw: ServiceEntry = {
|
|
890
877
|
name: "claw",
|
|
891
878
|
port: 1944,
|
|
@@ -893,70 +880,37 @@ describe("claw → agent migration", () => {
|
|
|
893
880
|
health: "/claw/health",
|
|
894
881
|
version: "0.1.0",
|
|
895
882
|
};
|
|
896
|
-
const agent: ServiceEntry = {
|
|
897
|
-
name: "agent",
|
|
898
|
-
port: 1944,
|
|
899
|
-
paths: ["/agent"],
|
|
900
|
-
health: "/agent/health",
|
|
901
|
-
version: "0.1.0",
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
test("rewrites name + paths + health when both name=claw and paths[0]=/claw", () => {
|
|
905
|
-
const { path, cleanup } = makeTempPath();
|
|
906
|
-
try {
|
|
907
|
-
writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
|
|
908
|
-
const got = readManifest(path);
|
|
909
|
-
// Migration ran in this read (claw → agent on raw entries), then
|
|
910
|
-
// the row was rewritten to disk. Post the 2026-06-17 channel→agent
|
|
911
|
-
// rename, `agent` is once again a LIVE module (the renamed channel
|
|
912
|
-
// module), so it is NO LONGER GC'd by RETIRED_MODULES — the migrated
|
|
913
|
-
// row persists and routes to the live agent module's mount.
|
|
914
|
-
expect(got.services).toEqual([agent]);
|
|
915
|
-
const reread = JSON.parse(readFileSync(path, "utf8")) as {
|
|
916
|
-
services: ServiceEntry[];
|
|
917
|
-
};
|
|
918
|
-
expect(reread.services[0]?.name).toBe("agent");
|
|
919
|
-
expect(reread.services[0]?.paths).toEqual(["/agent"]);
|
|
920
|
-
expect(reread.services[0]?.health).toBe("/agent/health");
|
|
921
|
-
} finally {
|
|
922
|
-
cleanup();
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
883
|
|
|
926
|
-
test("
|
|
927
|
-
// Pre-rename this was a one-step retirement path (claw → agent → GC'd).
|
|
928
|
-
// After the channel→agent rename (2026-06-17) `agent`/`parachute-agent`
|
|
929
|
-
// are re-assigned to the live module, so the row is NOT dropped — a
|
|
930
|
-
// stale paraclaw row now ends up pointing at the live agent module
|
|
931
|
-
// (harmless / arguably correct, since paraclaw was the original "agent").
|
|
884
|
+
test("strict reads retire the structural claw row immediately and persist the cleanup", () => {
|
|
932
885
|
const { path, cleanup } = makeTempPath();
|
|
933
886
|
try {
|
|
934
887
|
writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
expect(second.services).toEqual([agent]);
|
|
888
|
+
expect(readManifest(path).services).toEqual([]);
|
|
889
|
+
const persisted = JSON.parse(readFileSync(path, "utf8")) as { services: ServiceEntry[] };
|
|
890
|
+
expect(persisted.services).toEqual([]);
|
|
939
891
|
} finally {
|
|
940
892
|
cleanup();
|
|
941
893
|
}
|
|
942
894
|
});
|
|
943
895
|
|
|
944
|
-
test("
|
|
945
|
-
// Pre-hub#334 this verified the migration was idempotent; hub#334 made it
|
|
946
|
-
// GC the agent row (agent was retired). After the channel→agent rename
|
|
947
|
-
// (2026-06-17) agent is live again, so the row round-trips unchanged —
|
|
948
|
-
// back to the original idempotent behavior.
|
|
896
|
+
test("lenient hot-path reads also hide structural claw and bare channel rows", () => {
|
|
949
897
|
const { path, cleanup } = makeTempPath();
|
|
950
898
|
try {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
899
|
+
const channel: ServiceEntry = {
|
|
900
|
+
name: "channel",
|
|
901
|
+
port: 1941,
|
|
902
|
+
paths: ["/channel"],
|
|
903
|
+
health: "/channel/health",
|
|
904
|
+
version: "0.1.0",
|
|
905
|
+
};
|
|
906
|
+
writeFileSync(path, `${JSON.stringify({ services: [claw, channel] }, null, 2)}\n`);
|
|
907
|
+
expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([]);
|
|
954
908
|
} finally {
|
|
955
909
|
cleanup();
|
|
956
910
|
}
|
|
957
911
|
});
|
|
958
912
|
|
|
959
|
-
test("mixed
|
|
913
|
+
test("mixed manifests retain supported rows while dropping historical Agent shapes", () => {
|
|
960
914
|
const { path, cleanup } = makeTempPath();
|
|
961
915
|
try {
|
|
962
916
|
const scribe: ServiceEntry = {
|
|
@@ -967,38 +921,13 @@ describe("claw → agent migration", () => {
|
|
|
967
921
|
version: "0.1.0",
|
|
968
922
|
};
|
|
969
923
|
writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
|
|
970
|
-
|
|
971
|
-
// First read: claw migrates to agent (retired GC didn't see `claw`
|
|
972
|
-
// on the way in). Vault + scribe round-trip unchanged.
|
|
973
|
-
expect(got.services).toHaveLength(3);
|
|
974
|
-
expect(got.services[0]).toEqual(vault);
|
|
975
|
-
expect(got.services[1]).toEqual(agent);
|
|
976
|
-
expect(got.services[2]).toEqual(scribe);
|
|
977
|
-
} finally {
|
|
978
|
-
cleanup();
|
|
979
|
-
}
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
test("preserves nested /claw paths when present (e.g. /claw/api)", () => {
|
|
983
|
-
const { path, cleanup } = makeTempPath();
|
|
984
|
-
try {
|
|
985
|
-
const clawNested: ServiceEntry = {
|
|
986
|
-
name: "claw",
|
|
987
|
-
port: 1944,
|
|
988
|
-
paths: ["/claw", "/claw/api"],
|
|
989
|
-
health: "/claw/api/health",
|
|
990
|
-
version: "0.1.0",
|
|
991
|
-
};
|
|
992
|
-
writeFileSync(path, `${JSON.stringify({ services: [clawNested] }, null, 2)}\n`);
|
|
993
|
-
const got = readManifest(path);
|
|
994
|
-
expect(got.services[0]?.paths).toEqual(["/agent", "/agent/api"]);
|
|
995
|
-
expect(got.services[0]?.health).toBe("/agent/api/health");
|
|
924
|
+
expect(readManifest(path).services).toEqual([vault, scribe]);
|
|
996
925
|
} finally {
|
|
997
926
|
cleanup();
|
|
998
927
|
}
|
|
999
928
|
});
|
|
1000
929
|
|
|
1001
|
-
test("leaves a row alone if name is claw but mount is something else
|
|
930
|
+
test("leaves a row alone if name is claw but mount is something else", () => {
|
|
1002
931
|
const { path, cleanup } = makeTempPath();
|
|
1003
932
|
try {
|
|
1004
933
|
const oddClaw: ServiceEntry = {
|
|
@@ -1010,6 +939,7 @@ describe("claw → agent migration", () => {
|
|
|
1010
939
|
};
|
|
1011
940
|
writeFileSync(path, `${JSON.stringify({ services: [oddClaw] }, null, 2)}\n`);
|
|
1012
941
|
expect(readManifest(path).services).toEqual([oddClaw]);
|
|
942
|
+
expect(readManifestLenient(path, { warn: () => {} }).services).toEqual([oddClaw]);
|
|
1013
943
|
} finally {
|
|
1014
944
|
cleanup();
|
|
1015
945
|
}
|