@openparachute/hub 0.7.1 → 0.7.2
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 +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -2151,9 +2151,19 @@ describe("handleSetupExposePost", () => {
|
|
|
2151
2151
|
});
|
|
2152
2152
|
});
|
|
2153
2153
|
|
|
2154
|
-
// --- hub#272 Item A
|
|
2155
|
-
|
|
2156
|
-
|
|
2154
|
+
// --- hub#272 Item A → OAuth-default MCP command (no auto-mint) ---
|
|
2155
|
+
//
|
|
2156
|
+
// History: hub#272 Item A auto-minted a full-scope operator token in the
|
|
2157
|
+
// expose POST + pre-filled the done-screen MCP command with a
|
|
2158
|
+
// `--header "Authorization: Bearer <token>"` flag (plus a masked-reveal /
|
|
2159
|
+
// Copy widget). Removed 2026-06-23 after Austen reported the header-auth
|
|
2160
|
+
// command broke his connect: vault/init is OAuth-default now (parachute-
|
|
2161
|
+
// vault #491), so the bare `claude mcp add` command — which triggers
|
|
2162
|
+
// browser OAuth on first use — is the correct UX. These tests pin the new
|
|
2163
|
+
// contract: no token minted/stored by default, and the done screen never
|
|
2164
|
+
// emits a Bearer header.
|
|
2165
|
+
|
|
2166
|
+
describe("done screen MCP command — OAuth-default, no auto-mint", () => {
|
|
2157
2167
|
let h: Harness;
|
|
2158
2168
|
beforeEach(() => {
|
|
2159
2169
|
h = makeHarness();
|
|
@@ -2191,7 +2201,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2191
2201
|
return { user, session, csrf };
|
|
2192
2202
|
}
|
|
2193
2203
|
|
|
2194
|
-
test("expose POST
|
|
2204
|
+
test("expose POST does NOT mint or store an operator token (setup_minted_token stays unset)", async () => {
|
|
2195
2205
|
const db = openHubDb(hubDbPath(h.dir));
|
|
2196
2206
|
try {
|
|
2197
2207
|
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
@@ -2218,42 +2228,26 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2218
2228
|
},
|
|
2219
2229
|
);
|
|
2220
2230
|
expect(res.status).toBe(303);
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
|
|
2224
|
-
const stored = getSetting(db, "setup_minted_token");
|
|
2225
|
-
expect(stored).toBeDefined();
|
|
2226
|
-
expect(stored?.split(".").length).toBe(3);
|
|
2231
|
+
// The auto-mint was removed (Austen's report) — vault is OAuth-
|
|
2232
|
+
// default, so nothing should land in hub_settings.
|
|
2233
|
+
expect(getSetting(db, "setup_minted_token")).toBeUndefined();
|
|
2227
2234
|
} finally {
|
|
2228
2235
|
db.close();
|
|
2229
2236
|
}
|
|
2230
2237
|
});
|
|
2231
2238
|
|
|
2232
|
-
test("
|
|
2239
|
+
test("expose POST JSON response carries no minted_token", async () => {
|
|
2233
2240
|
const db = openHubDb(hubDbPath(h.dir));
|
|
2234
2241
|
try {
|
|
2235
|
-
const
|
|
2236
|
-
|
|
2237
|
-
{
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
health: "/health",
|
|
2245
|
-
},
|
|
2246
|
-
],
|
|
2247
|
-
},
|
|
2248
|
-
h.manifestPath,
|
|
2249
|
-
);
|
|
2250
|
-
setSetting(db, "setup_expose_mode", "localhost");
|
|
2251
|
-
setSetting(db, "setup_minted_token", "test-jwt-token-abc");
|
|
2252
|
-
const { createSession } = await import("../sessions.ts");
|
|
2253
|
-
const session = createSession(db, { userId: user.id });
|
|
2254
|
-
const res = handleSetupGet(
|
|
2255
|
-
req("/admin/setup?just_finished=1", {
|
|
2256
|
-
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
2242
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
2243
|
+
const res = await handleSetupExposePost(
|
|
2244
|
+
req("/admin/setup/expose", {
|
|
2245
|
+
method: "POST",
|
|
2246
|
+
body: JSON.stringify({ expose_mode: "localhost", [CSRF_FIELD_NAME]: csrf }),
|
|
2247
|
+
headers: {
|
|
2248
|
+
"content-type": "application/json",
|
|
2249
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
2250
|
+
},
|
|
2257
2251
|
}),
|
|
2258
2252
|
{
|
|
2259
2253
|
db,
|
|
@@ -2265,38 +2259,16 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2265
2259
|
},
|
|
2266
2260
|
);
|
|
2267
2261
|
expect(res.status).toBe(200);
|
|
2268
|
-
const
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
// (entities aren't parsed), so JSON encoding round-trips through
|
|
2272
|
-
// textContent + JSON.parse without `"` polluting the copied
|
|
2273
|
-
// command. Verify the JSON-encoded form appears in the document.
|
|
2274
|
-
expect(html).toContain(
|
|
2275
|
-
'"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp --header \\"Authorization: Bearer test-jwt-token-abc\\""',
|
|
2276
|
-
);
|
|
2277
|
-
expect(html).toContain('id="mcp-cmd"');
|
|
2278
|
-
expect(html).toContain('id="mcp-cmd-real"');
|
|
2279
|
-
// The hidden stash is `<script type="application/json">` so the
|
|
2280
|
-
// browser doesn't execute it but textContent is still readable.
|
|
2281
|
-
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
2282
|
-
// The visible default state is masked: the <pre> body is wrapped
|
|
2283
|
-
// with data-state="masked" and renders • placeholder characters
|
|
2284
|
-
// rather than the live token. Verified by the masked Bearer
|
|
2285
|
-
// header substring (• repeated).
|
|
2286
|
-
expect(html).toContain('data-state="masked"');
|
|
2287
|
-
expect(html).toMatch(/Bearer •+/);
|
|
2288
|
-
// Show button + Copy button both present.
|
|
2289
|
-
expect(html).toContain('id="mcp-cmd-show"');
|
|
2290
|
-
expect(html).toContain('id="mcp-cmd-copy"');
|
|
2291
|
-
expect(html).toContain("/admin/tokens");
|
|
2292
|
-
// The token is single-use — consumed on first render.
|
|
2262
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
2263
|
+
expect(json.step).toBe("done");
|
|
2264
|
+
expect(json).not.toHaveProperty("minted_token");
|
|
2293
2265
|
expect(getSetting(db, "setup_minted_token")).toBeUndefined();
|
|
2294
2266
|
} finally {
|
|
2295
2267
|
db.close();
|
|
2296
2268
|
}
|
|
2297
2269
|
});
|
|
2298
2270
|
|
|
2299
|
-
test("done screen
|
|
2271
|
+
test("done screen renders the bare OAuth MCP command with NO Bearer header by default", async () => {
|
|
2300
2272
|
const db = openHubDb(hubDbPath(h.dir));
|
|
2301
2273
|
try {
|
|
2302
2274
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -2330,140 +2302,57 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2330
2302
|
registry: getDefaultOperationsRegistry(),
|
|
2331
2303
|
},
|
|
2332
2304
|
);
|
|
2305
|
+
expect(res.status).toBe(200);
|
|
2333
2306
|
const html = await res.text();
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
//
|
|
2307
|
+
// The bare OAuth command is the rendered command.
|
|
2308
|
+
expect(html).toContain(
|
|
2309
|
+
"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp",
|
|
2310
|
+
);
|
|
2311
|
+
// Load-bearing regression (Austen's report): the rendered COMMAND
|
|
2312
|
+
// (the MCP tile's <pre> block) must NOT carry a `--header
|
|
2313
|
+
// "Authorization: Bearer ..."` flag. This is the assertion that
|
|
2314
|
+
// would have caught the bug — the prior build baked the header into
|
|
2315
|
+
// the command. The headless-client fine print legitimately mentions
|
|
2316
|
+
// the header form with an escaped `<token>` placeholder, so we
|
|
2317
|
+
// scope the negative assertion to the command block, not the whole
|
|
2318
|
+
// document. Anchor to the MCP-tile <h2> so the regex can't drift
|
|
2319
|
+
// onto another tile's <pre> (e.g. the tailnet/public reachable
|
|
2320
|
+
// tile's bare Tailscale command) under a different expose mode.
|
|
2321
|
+
const cmdPre = html.match(/<h2>Connect Claude Code \(MCP\)<\/h2>[\s\S]*?<pre>([^<]*)<\/pre>/);
|
|
2322
|
+
expect(cmdPre).not.toBeNull();
|
|
2323
|
+
const cmdText = cmdPre?.[1] ?? "";
|
|
2324
|
+
expect(cmdText).not.toContain("--header");
|
|
2325
|
+
expect(cmdText).not.toContain("Authorization");
|
|
2326
|
+
expect(cmdText).not.toContain("Bearer");
|
|
2327
|
+
// The document must NOT carry a real or masked Bearer token — the
|
|
2328
|
+
// only legitimate `Bearer` mention is the escaped placeholder in
|
|
2329
|
+
// the guidance.
|
|
2330
|
+
expect(html).not.toMatch(/Bearer •/);
|
|
2331
|
+
expect(html).not.toContain("Authorization: Bearer test");
|
|
2332
|
+
// Explanatory text leads with the OAuth path; the only mention of
|
|
2333
|
+
// a Bearer header is the escaped `<token>` placeholder in the
|
|
2334
|
+
// headless-client guidance.
|
|
2339
2335
|
expect(html).toContain("browser OAuth");
|
|
2340
2336
|
expect(html).toContain("Bearer <token>");
|
|
2341
2337
|
expect(html).not.toContain("pvt_");
|
|
2342
2338
|
expect(html).toContain("parachute auth mint-token");
|
|
2343
2339
|
expect(html).toContain("/admin/tokens");
|
|
2344
|
-
//
|
|
2340
|
+
// None of the masked-token / Copy-widget DOM survives — the
|
|
2341
|
+
// token-present branch was removed.
|
|
2345
2342
|
expect(html).not.toContain('id="mcp-cmd"');
|
|
2343
|
+
expect(html).not.toContain('id="mcp-cmd-real"');
|
|
2344
|
+
expect(html).not.toContain('id="mcp-cmd-copy"');
|
|
2345
|
+
expect(html).not.toContain('data-state="masked"');
|
|
2346
2346
|
} finally {
|
|
2347
2347
|
db.close();
|
|
2348
2348
|
}
|
|
2349
2349
|
});
|
|
2350
2350
|
|
|
2351
|
-
test("
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
{
|
|
2357
|
-
services: [
|
|
2358
|
-
{
|
|
2359
|
-
name: "parachute-vault",
|
|
2360
|
-
version: "0.1.0",
|
|
2361
|
-
port: 1940,
|
|
2362
|
-
paths: ["/vault/default"],
|
|
2363
|
-
health: "/health",
|
|
2364
|
-
},
|
|
2365
|
-
],
|
|
2366
|
-
},
|
|
2367
|
-
h.manifestPath,
|
|
2368
|
-
);
|
|
2369
|
-
setSetting(db, "setup_expose_mode", "localhost");
|
|
2370
|
-
setSetting(db, "setup_minted_token", "test-token-xyz");
|
|
2371
|
-
const { createSession } = await import("../sessions.ts");
|
|
2372
|
-
const session = createSession(db, { userId: user.id });
|
|
2373
|
-
const deps = {
|
|
2374
|
-
db,
|
|
2375
|
-
manifestPath: h.manifestPath,
|
|
2376
|
-
configDir: h.dir,
|
|
2377
|
-
readExposeStateFn: h.readExposeStateFn,
|
|
2378
|
-
issuer: "https://hub.example",
|
|
2379
|
-
registry: getDefaultOperationsRegistry(),
|
|
2380
|
-
};
|
|
2381
|
-
const sessionedReq = () =>
|
|
2382
|
-
req("/admin/setup?just_finished=1", {
|
|
2383
|
-
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
2384
|
-
});
|
|
2385
|
-
const first = handleSetupGet(sessionedReq(), deps);
|
|
2386
|
-
const firstHtml = await first.text();
|
|
2387
|
-
expect(firstHtml).toContain("test-token-xyz");
|
|
2388
|
-
const second = handleSetupGet(sessionedReq(), deps);
|
|
2389
|
-
const secondHtml = await second.text();
|
|
2390
|
-
expect(secondHtml).not.toContain("test-token-xyz");
|
|
2391
|
-
// The MCP command tile has no Copy button on the fallback shape.
|
|
2392
|
-
expect(secondHtml).not.toContain('id="mcp-cmd"');
|
|
2393
|
-
} finally {
|
|
2394
|
-
db.close();
|
|
2395
|
-
}
|
|
2396
|
-
});
|
|
2397
|
-
|
|
2398
|
-
// rc.11 — token visible by default on the done screen was a
|
|
2399
|
-
// shoulder-surf hazard. The fix: render the visible command with
|
|
2400
|
-
// a masked Bearer token, stash the real command in a
|
|
2401
|
-
// hidden script tag, and surface a Show button + Copy button. Copy
|
|
2402
|
-
// ALWAYS pulls the real command from the script tag so the
|
|
2403
|
-
// operator's terminal paste never breaks regardless of mask state.
|
|
2404
|
-
test("done screen masks the Bearer token in the visible <pre> by default", async () => {
|
|
2405
|
-
const db = openHubDb(hubDbPath(h.dir));
|
|
2406
|
-
try {
|
|
2407
|
-
const user = await createUser(db, "owner", "pw");
|
|
2408
|
-
writeManifest(
|
|
2409
|
-
{
|
|
2410
|
-
services: [
|
|
2411
|
-
{
|
|
2412
|
-
name: "parachute-vault",
|
|
2413
|
-
version: "0.1.0",
|
|
2414
|
-
port: 1940,
|
|
2415
|
-
paths: ["/vault/default"],
|
|
2416
|
-
health: "/health",
|
|
2417
|
-
},
|
|
2418
|
-
],
|
|
2419
|
-
},
|
|
2420
|
-
h.manifestPath,
|
|
2421
|
-
);
|
|
2422
|
-
setSetting(db, "setup_expose_mode", "localhost");
|
|
2423
|
-
setSetting(db, "setup_minted_token", "pvt_super_secret_token_payload");
|
|
2424
|
-
const { createSession } = await import("../sessions.ts");
|
|
2425
|
-
const session = createSession(db, { userId: user.id });
|
|
2426
|
-
const res = handleSetupGet(
|
|
2427
|
-
req("/admin/setup?just_finished=1", {
|
|
2428
|
-
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
2429
|
-
}),
|
|
2430
|
-
{
|
|
2431
|
-
db,
|
|
2432
|
-
manifestPath: h.manifestPath,
|
|
2433
|
-
configDir: h.dir,
|
|
2434
|
-
readExposeStateFn: h.readExposeStateFn,
|
|
2435
|
-
issuer: "https://hub.example",
|
|
2436
|
-
registry: getDefaultOperationsRegistry(),
|
|
2437
|
-
},
|
|
2438
|
-
);
|
|
2439
|
-
const html = await res.text();
|
|
2440
|
-
// Extract the visible <pre id="mcp-cmd"> text only — the masked
|
|
2441
|
-
// shape must live there, with no occurrence of the literal token
|
|
2442
|
-
// string. The real token still appears elsewhere (the hidden
|
|
2443
|
-
// script tag) so a plain `toContain` would miss the leak.
|
|
2444
|
-
const preMatch = html.match(/<pre id="mcp-cmd">([^<]*)<\/pre>/);
|
|
2445
|
-
expect(preMatch).not.toBeNull();
|
|
2446
|
-
const preBody = preMatch?.[1] ?? "";
|
|
2447
|
-
expect(preBody).not.toContain("pvt_super_secret_token_payload");
|
|
2448
|
-
// Masked Bearer header is present in the <pre> text.
|
|
2449
|
-
expect(preBody).toMatch(/Bearer •+/);
|
|
2450
|
-
// Real command still in the document (hidden JSON stash) so the
|
|
2451
|
-
// Copy handler can read it.
|
|
2452
|
-
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
2453
|
-
expect(html).toContain("pvt_super_secret_token_payload");
|
|
2454
|
-
// Default state is masked.
|
|
2455
|
-
expect(html).toContain('data-state="masked"');
|
|
2456
|
-
} finally {
|
|
2457
|
-
db.close();
|
|
2458
|
-
}
|
|
2459
|
-
});
|
|
2460
|
-
|
|
2461
|
-
test("done screen JSON-encodes the stashed command so `</script>` in a token can't break out", async () => {
|
|
2462
|
-
// Defense-in-depth: an attacker-shaped token containing `</script>`
|
|
2463
|
-
// would prematurely close the stash tag if we just dropped it into
|
|
2464
|
-
// the HTML. The renderer JSON-encodes the command AND replaces
|
|
2465
|
-
// `</` with `<\/` inside the encoded string so the sequence can't
|
|
2466
|
-
// appear in the document. Decode round-trips via JSON.parse.
|
|
2351
|
+
test("done screen never renders a stale setup_minted_token row + clears it defensively", async () => {
|
|
2352
|
+
// Upgrade path: a pre-upgrade hub may have left a stale
|
|
2353
|
+
// setup_minted_token row behind. The done-step GET must never paint
|
|
2354
|
+
// it (no token surface anymore) and should clear it so it can't
|
|
2355
|
+
// linger.
|
|
2467
2356
|
const db = openHubDb(hubDbPath(h.dir));
|
|
2468
2357
|
try {
|
|
2469
2358
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -2482,11 +2371,8 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2482
2371
|
h.manifestPath,
|
|
2483
2372
|
);
|
|
2484
2373
|
setSetting(db, "setup_expose_mode", "localhost");
|
|
2485
|
-
//
|
|
2486
|
-
|
|
2487
|
-
// string) and `</script>` (would close the stash tag).
|
|
2488
|
-
const hostileToken = `weird-token-with-"-and-</script>-inside`;
|
|
2489
|
-
setSetting(db, "setup_minted_token", hostileToken);
|
|
2374
|
+
// Plant a stale row as if a pre-upgrade hub had minted one.
|
|
2375
|
+
setSetting(db, "setup_minted_token", "stale-token-from-old-hub");
|
|
2490
2376
|
const { createSession } = await import("../sessions.ts");
|
|
2491
2377
|
const session = createSession(db, { userId: user.id });
|
|
2492
2378
|
const res = handleSetupGet(
|
|
@@ -2503,95 +2389,24 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2503
2389
|
},
|
|
2504
2390
|
);
|
|
2505
2391
|
const html = await res.text();
|
|
2506
|
-
//
|
|
2507
|
-
//
|
|
2508
|
-
//
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
);
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
// The encoder replaces `</` with `<\/` inside the JSON, so the
|
|
2516
|
-
// raw bytes between the opening and the first `</script>` should
|
|
2517
|
-
// not contain `</`.
|
|
2518
|
-
expect(stashBody).not.toContain("</");
|
|
2519
|
-
// Round-trips: `<\/` decodes back to `</` after JSON.parse +
|
|
2520
|
-
// the script-end-sequence escape — the operator's clipboard
|
|
2521
|
-
// gets the original bytes.
|
|
2522
|
-
const decoded = JSON.parse(stashBody) as string;
|
|
2523
|
-
expect(decoded).toContain(hostileToken);
|
|
2524
|
-
} finally {
|
|
2525
|
-
db.close();
|
|
2526
|
-
}
|
|
2527
|
-
});
|
|
2528
|
-
|
|
2529
|
-
test("done screen wires Show + Copy buttons that read from the hidden real-command stash", async () => {
|
|
2530
|
-
const db = openHubDb(hubDbPath(h.dir));
|
|
2531
|
-
try {
|
|
2532
|
-
const user = await createUser(db, "owner", "pw");
|
|
2533
|
-
writeManifest(
|
|
2534
|
-
{
|
|
2535
|
-
services: [
|
|
2536
|
-
{
|
|
2537
|
-
name: "parachute-vault",
|
|
2538
|
-
version: "0.1.0",
|
|
2539
|
-
port: 1940,
|
|
2540
|
-
paths: ["/vault/default"],
|
|
2541
|
-
health: "/health",
|
|
2542
|
-
},
|
|
2543
|
-
],
|
|
2544
|
-
},
|
|
2545
|
-
h.manifestPath,
|
|
2546
|
-
);
|
|
2547
|
-
setSetting(db, "setup_expose_mode", "localhost");
|
|
2548
|
-
setSetting(db, "setup_minted_token", "live-token-AAA");
|
|
2549
|
-
const { createSession } = await import("../sessions.ts");
|
|
2550
|
-
const session = createSession(db, { userId: user.id });
|
|
2551
|
-
const res = handleSetupGet(
|
|
2552
|
-
req("/admin/setup?just_finished=1", {
|
|
2553
|
-
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
2554
|
-
}),
|
|
2555
|
-
{
|
|
2556
|
-
db,
|
|
2557
|
-
manifestPath: h.manifestPath,
|
|
2558
|
-
configDir: h.dir,
|
|
2559
|
-
readExposeStateFn: h.readExposeStateFn,
|
|
2560
|
-
issuer: "https://hub.example",
|
|
2561
|
-
registry: getDefaultOperationsRegistry(),
|
|
2562
|
-
},
|
|
2563
|
-
);
|
|
2564
|
-
const html = await res.text();
|
|
2565
|
-
// Both buttons present, both wired via addEventListener (no
|
|
2566
|
-
// inline onclick — the script runs in a single IIFE).
|
|
2567
|
-
expect(html).toContain('id="mcp-cmd-show"');
|
|
2568
|
-
expect(html).toContain('id="mcp-cmd-copy"');
|
|
2569
|
-
expect(html).toContain("'click'");
|
|
2570
|
-
// The Copy handler reads from the hidden script tag, not from
|
|
2571
|
-
// the visible <pre>. Regression: this was the load-bearing
|
|
2572
|
-
// contract Aaron called out ("Copy still works without reveal").
|
|
2573
|
-
expect(html).toContain("getElementById('mcp-cmd-real')");
|
|
2574
|
-
// The stash holds JSON-encoded text and the handler decodes via
|
|
2575
|
-
// JSON.parse so the clipboard receives the exact byte sequence of
|
|
2576
|
-
// the command — `"`-style HTML entities can't survive into
|
|
2577
|
-
// the operator's shell because script-element content is raw text
|
|
2578
|
-
// (the HTML parser doesn't decode entities inside <script>).
|
|
2579
|
-
expect(html).toContain("JSON.parse(real.textContent");
|
|
2580
|
-
// Auto-hide timer present so a stray reveal doesn't leak into a
|
|
2581
|
-
// subsequent screencast capture.
|
|
2582
|
-
expect(html).toContain("setTimeout(setMasked, 10000)");
|
|
2392
|
+
// The stale token is NOT painted anywhere in the document, and the
|
|
2393
|
+
// rendered command carries no Bearer header (the only `Bearer`
|
|
2394
|
+
// mention is the escaped `<token>` placeholder in the guidance).
|
|
2395
|
+
expect(html).not.toContain("stale-token-from-old-hub");
|
|
2396
|
+
expect(html).not.toContain("Authorization: Bearer stale-token-from-old-hub");
|
|
2397
|
+
const cmdPre = html.match(/<h2>Connect Claude Code \(MCP\)<\/h2>[\s\S]*?<pre>([^<]*)<\/pre>/);
|
|
2398
|
+
expect(cmdPre?.[1] ?? "").not.toContain("Authorization");
|
|
2399
|
+
// And the row is cleared so it can't linger across renders.
|
|
2400
|
+
expect(getSetting(db, "setup_minted_token")).toBeUndefined();
|
|
2583
2401
|
} finally {
|
|
2584
2402
|
db.close();
|
|
2585
2403
|
}
|
|
2586
2404
|
});
|
|
2587
2405
|
|
|
2588
|
-
test("GET /admin/setup?just_finished=1 without a session
|
|
2589
|
-
//
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
// full-scope operator JWT. The gate sends sessionless GETs to
|
|
2593
|
-
// /login + leaves the row in place so the operator's subsequent
|
|
2594
|
-
// legitimate GET still surfaces the token.
|
|
2406
|
+
test("GET /admin/setup?just_finished=1 without a session redirects to /login (done screen is post-setup admin)", async () => {
|
|
2407
|
+
// The done screen is a post-setup admin surface and shouldn't render
|
|
2408
|
+
// to a drive-by unauthenticated GET. (This gate originally protected
|
|
2409
|
+
// the minted-token read; the mint is gone, but the gate stays.)
|
|
2595
2410
|
const db = openHubDb(hubDbPath(h.dir));
|
|
2596
2411
|
try {
|
|
2597
2412
|
await createUser(db, "owner", "pw");
|
|
@@ -2610,10 +2425,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2610
2425
|
h.manifestPath,
|
|
2611
2426
|
);
|
|
2612
2427
|
setSetting(db, "setup_expose_mode", "localhost");
|
|
2613
|
-
|
|
2614
|
-
// No session cookie on this request — simulating a drive-by GET
|
|
2615
|
-
// from an attacker or a stale bookmark in a different browser
|
|
2616
|
-
// tab that doesn't carry the wizard's session.
|
|
2428
|
+
// No session cookie — a drive-by GET.
|
|
2617
2429
|
const res = handleSetupGet(req("/admin/setup?just_finished=1"), {
|
|
2618
2430
|
db,
|
|
2619
2431
|
manifestPath: h.manifestPath,
|
|
@@ -2622,14 +2434,8 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
|
2622
2434
|
issuer: "https://hub.example",
|
|
2623
2435
|
registry: getDefaultOperationsRegistry(),
|
|
2624
2436
|
});
|
|
2625
|
-
// The gate redirects to /login (302) rather than rendering the
|
|
2626
|
-
// done screen. Body must NOT contain the token.
|
|
2627
2437
|
expect(res.status).toBe(302);
|
|
2628
2438
|
expect(res.headers.get("location")).toBe("/login");
|
|
2629
|
-
// The setup_minted_token row is STILL present — the unauthed GET
|
|
2630
|
-
// didn't consume it, so the legitimate operator's session-bearing
|
|
2631
|
-
// GET will still see the token on the done screen.
|
|
2632
|
-
expect(getSetting(db, "setup_minted_token")).toBe("test-secret-token-must-not-leak");
|
|
2633
2439
|
} finally {
|
|
2634
2440
|
db.close();
|
|
2635
2441
|
}
|
|
@@ -121,7 +121,7 @@ describe("setup", () => {
|
|
|
121
121
|
{ name: "parachute-vault", port: 1940 },
|
|
122
122
|
{ name: "parachute-notes", port: 1942 },
|
|
123
123
|
{ name: "parachute-scribe", port: 1943 },
|
|
124
|
-
{ name: "parachute-
|
|
124
|
+
{ name: "parachute-agent", port: 1941 },
|
|
125
125
|
{ name: "parachute-runner", port: 1945 },
|
|
126
126
|
{ name: "parachute-surface", port: 1946 },
|
|
127
127
|
];
|