@openparachute/hub 0.6.4-rc.8 → 0.6.4-rc.9
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
|
@@ -1464,6 +1464,109 @@ describe("exposeCloudflareOff", () => {
|
|
|
1464
1464
|
}
|
|
1465
1465
|
});
|
|
1466
1466
|
|
|
1467
|
+
test("clears stale PARACHUTE_HUB_ORIGIN from vault/.env on last-tunnel down (#503)", async () => {
|
|
1468
|
+
const env = makeEnv();
|
|
1469
|
+
try {
|
|
1470
|
+
// Seed the stale public origin the up-path persisted into vault/.env.
|
|
1471
|
+
// After teardown the hub is loopback-only, so leaving this would pin a
|
|
1472
|
+
// public expected issuer and 401 every request on the next vault restart.
|
|
1473
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1474
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1475
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1476
|
+
|
|
1477
|
+
writeCloudflaredState(
|
|
1478
|
+
{
|
|
1479
|
+
version: 2,
|
|
1480
|
+
tunnels: {
|
|
1481
|
+
parachute: {
|
|
1482
|
+
pid: 55557,
|
|
1483
|
+
tunnelUuid: "ffffffff-0000-0000-0000-000000000006",
|
|
1484
|
+
tunnelName: "parachute",
|
|
1485
|
+
hostname: "vault.example.com",
|
|
1486
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1487
|
+
configPath: env.configPath,
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
env.statePath,
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const logs: string[] = [];
|
|
1495
|
+
const code = await exposeCloudflareOff({
|
|
1496
|
+
configDir: env.configDir,
|
|
1497
|
+
statePath: env.statePath,
|
|
1498
|
+
exposeStatePath: env.exposeStatePath,
|
|
1499
|
+
alive: () => false,
|
|
1500
|
+
kill: () => {},
|
|
1501
|
+
log: (l) => logs.push(l),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
expect(code).toBe(0);
|
|
1505
|
+
// The stale public origin is gone — vault reverts to its loopback default.
|
|
1506
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
1507
|
+
// Operator is told what to restart so a running vault picks up the change.
|
|
1508
|
+
expect(logs.join("\n")).toContain("cleared PARACHUTE_HUB_ORIGIN");
|
|
1509
|
+
expect(logs.join("\n")).toContain("parachute restart vault");
|
|
1510
|
+
} finally {
|
|
1511
|
+
env.cleanup();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("leaves vault/.env untouched while other tunnels survive (#503)", async () => {
|
|
1516
|
+
const env = makeEnv();
|
|
1517
|
+
try {
|
|
1518
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1519
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1520
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1521
|
+
|
|
1522
|
+
// Two tunnels; tear down only one by name → the box stays exposed, so the
|
|
1523
|
+
// persisted public origin must remain (clearing it would break the live
|
|
1524
|
+
// tunnel's iss check). Symmetric with the expose-state.json retention.
|
|
1525
|
+
writeCloudflaredState(
|
|
1526
|
+
{
|
|
1527
|
+
version: 2,
|
|
1528
|
+
tunnels: {
|
|
1529
|
+
alpha: {
|
|
1530
|
+
pid: 55558,
|
|
1531
|
+
tunnelUuid: "11111111-0000-0000-0000-000000000007",
|
|
1532
|
+
tunnelName: "alpha",
|
|
1533
|
+
hostname: "alpha.example.com",
|
|
1534
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1535
|
+
configPath: env.configPath,
|
|
1536
|
+
},
|
|
1537
|
+
beta: {
|
|
1538
|
+
pid: 55559,
|
|
1539
|
+
tunnelUuid: "22222222-0000-0000-0000-000000000008",
|
|
1540
|
+
tunnelName: "beta",
|
|
1541
|
+
hostname: "beta.example.com",
|
|
1542
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1543
|
+
configPath: env.configPath,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
env.statePath,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
const code = await exposeCloudflareOff({
|
|
1551
|
+
configDir: env.configDir,
|
|
1552
|
+
tunnelName: "alpha",
|
|
1553
|
+
statePath: env.statePath,
|
|
1554
|
+
exposeStatePath: env.exposeStatePath,
|
|
1555
|
+
alive: () => false,
|
|
1556
|
+
kill: () => {},
|
|
1557
|
+
log: () => {},
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
expect(code).toBe(0);
|
|
1561
|
+
// Beta tunnel survives → public origin stays.
|
|
1562
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBe(
|
|
1563
|
+
"https://vault.example.com",
|
|
1564
|
+
);
|
|
1565
|
+
} finally {
|
|
1566
|
+
env.cleanup();
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1467
1570
|
test("clears stale state when the process is already gone", async () => {
|
|
1468
1571
|
const env = makeEnv();
|
|
1469
1572
|
try {
|
|
@@ -1630,6 +1630,139 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1630
1630
|
}
|
|
1631
1631
|
});
|
|
1632
1632
|
|
|
1633
|
+
// #525: bare `/vault/<name>` POST (no `/mcp` suffix) half-connects MCP
|
|
1634
|
+
// clients. The fix 308-redirects the bare-path POST to `<mount>/mcp` BEFORE
|
|
1635
|
+
// proxying, so a compliant client re-POSTs to the right endpoint and clients
|
|
1636
|
+
// that don't follow redirects at least get an actionable Location + JSON body
|
|
1637
|
+
// (vs the old opaque vault 405).
|
|
1638
|
+
test("POST to bare /vault/<name> 308-redirects to /vault/<name>/mcp with an actionable body (#525)", async () => {
|
|
1639
|
+
const h = makeHarness();
|
|
1640
|
+
try {
|
|
1641
|
+
writeManifest({ services: [vaultEntry("aaron")] }, h.manifestPath);
|
|
1642
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1643
|
+
const res = await fetcher(
|
|
1644
|
+
req("/vault/aaron", {
|
|
1645
|
+
method: "POST",
|
|
1646
|
+
headers: { "content-type": "application/json" },
|
|
1647
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
expect(res.status).toBe(308);
|
|
1651
|
+
expect(res.headers.get("location")).toBe("/vault/aaron/mcp");
|
|
1652
|
+
// 308 is permanently cacheable by default — no-store prevents a cached
|
|
1653
|
+
// redirect outliving a remount.
|
|
1654
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1655
|
+
const body = (await res.json()) as { error: string; mcp_url: string; message: string };
|
|
1656
|
+
expect(body.error).toBe("missing_mcp_suffix");
|
|
1657
|
+
expect(body.mcp_url).toBe("/vault/aaron/mcp");
|
|
1658
|
+
expect(body.message).toContain("/vault/aaron/mcp");
|
|
1659
|
+
} finally {
|
|
1660
|
+
h.cleanup();
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test("bare-path 308 honors a trailing-slash mount: POST /vault/default → /vault/default/mcp (#525)", async () => {
|
|
1665
|
+
// Mounts in services.json sometimes carry a trailing slash (#197). The
|
|
1666
|
+
// redirect target must be built from the *normalized* mount so it stays
|
|
1667
|
+
// `/vault/default/mcp`, never `/vault/default//mcp`.
|
|
1668
|
+
const h = makeHarness();
|
|
1669
|
+
try {
|
|
1670
|
+
writeManifest(
|
|
1671
|
+
{
|
|
1672
|
+
services: [
|
|
1673
|
+
{
|
|
1674
|
+
name: "parachute-vault-default",
|
|
1675
|
+
port: 1940,
|
|
1676
|
+
paths: ["/vault/default/"],
|
|
1677
|
+
health: "/health",
|
|
1678
|
+
version: "0.4.0",
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
h.manifestPath,
|
|
1683
|
+
);
|
|
1684
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1685
|
+
const res = await fetcher(req("/vault/default", { method: "POST" }));
|
|
1686
|
+
expect(res.status).toBe(308);
|
|
1687
|
+
expect(res.headers.get("location")).toBe("/vault/default/mcp");
|
|
1688
|
+
} finally {
|
|
1689
|
+
h.cleanup();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("GET to bare /vault/<name> is NOT redirected — proxies through untouched (#525)", async () => {
|
|
1694
|
+
// Only POST (the MCP transport verb) is caught. A browser GET to the bare
|
|
1695
|
+
// path keeps its existing proxy behavior so we don't break any bare-path
|
|
1696
|
+
// GET surface.
|
|
1697
|
+
const h = makeHarness();
|
|
1698
|
+
const upstream = startUpstream("bare-get");
|
|
1699
|
+
try {
|
|
1700
|
+
writeManifest(
|
|
1701
|
+
{
|
|
1702
|
+
services: [
|
|
1703
|
+
{
|
|
1704
|
+
name: "parachute-vault-aaron",
|
|
1705
|
+
port: upstream.port,
|
|
1706
|
+
paths: ["/vault/aaron"],
|
|
1707
|
+
health: "/health",
|
|
1708
|
+
version: "0.4.0",
|
|
1709
|
+
},
|
|
1710
|
+
],
|
|
1711
|
+
},
|
|
1712
|
+
h.manifestPath,
|
|
1713
|
+
);
|
|
1714
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1715
|
+
const res = await fetcher(req("/vault/aaron", { method: "GET" }));
|
|
1716
|
+
expect(res.status).toBe(200);
|
|
1717
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1718
|
+
expect(body.tag).toBe("bare-get");
|
|
1719
|
+
expect(body.method).toBe("GET");
|
|
1720
|
+
expect(body.pathname).toBe("/vault/aaron");
|
|
1721
|
+
} finally {
|
|
1722
|
+
upstream.stop();
|
|
1723
|
+
h.cleanup();
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("POST to the canonical /vault/<name>/mcp sub-path is NOT redirected — proxies through (#525)", async () => {
|
|
1728
|
+
// The real MCP endpoint must keep working: a POST that already carries the
|
|
1729
|
+
// `/mcp` suffix is a sub-path (not the exact bare mount) and proxies
|
|
1730
|
+
// straight to the vault backend.
|
|
1731
|
+
const h = makeHarness();
|
|
1732
|
+
const upstream = startUpstream("mcp-post");
|
|
1733
|
+
try {
|
|
1734
|
+
writeManifest(
|
|
1735
|
+
{
|
|
1736
|
+
services: [
|
|
1737
|
+
{
|
|
1738
|
+
name: "parachute-vault-aaron",
|
|
1739
|
+
port: upstream.port,
|
|
1740
|
+
paths: ["/vault/aaron"],
|
|
1741
|
+
health: "/health",
|
|
1742
|
+
version: "0.4.0",
|
|
1743
|
+
},
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
h.manifestPath,
|
|
1747
|
+
);
|
|
1748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1749
|
+
const res = await fetcher(
|
|
1750
|
+
req("/vault/aaron/mcp", {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
|
|
1753
|
+
}),
|
|
1754
|
+
);
|
|
1755
|
+
expect(res.status).toBe(200);
|
|
1756
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1757
|
+
expect(body.tag).toBe("mcp-post");
|
|
1758
|
+
expect(body.method).toBe("POST");
|
|
1759
|
+
expect(body.pathname).toBe("/vault/aaron/mcp");
|
|
1760
|
+
} finally {
|
|
1761
|
+
upstream.stop();
|
|
1762
|
+
h.cleanup();
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1633
1766
|
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1634
1767
|
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1635
1768
|
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
@@ -53,6 +53,7 @@ import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
|
|
|
53
53
|
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
54
54
|
import { readManifestLenient } from "../services-manifest.ts";
|
|
55
55
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
56
|
+
import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
56
57
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
57
58
|
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
58
59
|
import {
|
|
@@ -1137,8 +1138,35 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
1137
1138
|
// downstream consumers stop resolving the now-dead public URL (mirrors the
|
|
1138
1139
|
// up-path write above + the Tailscale off-path's expose-state teardown). When
|
|
1139
1140
|
// other tunnels survive we leave it — a later off for the last one clears it.
|
|
1141
|
+
//
|
|
1142
|
+
// TODO(multi-tunnel) #588: with TWO CF tunnels up, tearing down the
|
|
1143
|
+
// last-written-up one (whose hostname is what's in vault's `.env`) while the
|
|
1144
|
+
// other survives leaves `.env` carrying the dead tunnel's origin while the
|
|
1145
|
+
// surviving tunnel serves a different one → stale-iss on the next vault
|
|
1146
|
+
// restart. Retention is still the only SAFE choice here: a single
|
|
1147
|
+
// `PARACHUTE_HUB_ORIGIN` field can't represent "which surviving tunnel wins,"
|
|
1148
|
+
// and clearing it would break the survivor's iss check. Properly fixing it
|
|
1149
|
+
// needs re-resolving the effective origin from the survivor (or multi-origin
|
|
1150
|
+
// issuer acceptance vault-side) — larger than the #503 single-tunnel fix, and
|
|
1151
|
+
// multi-CF-tunnel-on-one-box is rare. See #588.
|
|
1140
1152
|
if (!state) {
|
|
1141
1153
|
clearExposeState(r.exposeStatePath);
|
|
1154
|
+
// Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env` (#503). With
|
|
1155
|
+
// the last Cloudflare tunnel gone, the hub is loopback-only and mints
|
|
1156
|
+
// loopback-`iss` tokens; a stale public origin left in `vault/.env` would
|
|
1157
|
+
// pin a public expected issuer and 401 every request on the next vault
|
|
1158
|
+
// daemon restart ("not signed in to the hub" — the inverse of the bug
|
|
1159
|
+
// selfHealVaultHubOrigin closed). This mirrors exactly what the Tailscale
|
|
1160
|
+
// off-path does (`exposeOff` in expose.ts) — the Cloudflare path had been
|
|
1161
|
+
// the asymmetric gap. expose-state's own `hubOrigin` is cleared above via
|
|
1162
|
+
// clearExposeState, so hub's per-request `resolveIssuer`/`exposeIssuerOrigin`
|
|
1163
|
+
// (which read expose-state) also stop minting the public iss after teardown.
|
|
1164
|
+
// No restart needed for the gap this closes — the next vault restart picks
|
|
1165
|
+
// up the cleared `.env` — but tell the operator so an already-running vault
|
|
1166
|
+
// doesn't keep validating against the now-dead public origin.
|
|
1167
|
+
if (clearVaultHubOrigin(r.configDir, r.log)) {
|
|
1168
|
+
r.log(" Restart vault to apply the loopback issuer now: `parachute restart vault`.");
|
|
1169
|
+
}
|
|
1142
1170
|
}
|
|
1143
1171
|
return failed ? 1 : 0;
|
|
1144
1172
|
}
|
package/src/help.ts
CHANGED
|
@@ -104,7 +104,7 @@ Examples:
|
|
|
104
104
|
parachute install vault # light: installs + starts vault, points you at the admin UI
|
|
105
105
|
parachute install vault --interactive # full interactive vault init (name / MCP / token prompts)
|
|
106
106
|
parachute install surface # installs surface (auto-bootstraps Notes)
|
|
107
|
-
parachute install notes #
|
|
107
|
+
parachute install notes # legacy notes-daemon — deprecated; use \`parachute install surface\` instead
|
|
108
108
|
parachute install scribe # installs, prompts for provider, starts scribe
|
|
109
109
|
parachute install scribe --scribe-provider groq --scribe-key gsk_…
|
|
110
110
|
# non-interactive scribe setup
|
package/src/hub-server.ts
CHANGED
|
@@ -643,6 +643,40 @@ async function proxyToVault(
|
|
|
643
643
|
) {
|
|
644
644
|
return new Response("not found", { status: 404 });
|
|
645
645
|
}
|
|
646
|
+
// Bare `/vault/<name>` POST → point at `/vault/<name>/mcp` (#525). Operators
|
|
647
|
+
// paste the bare vault URL (no `/mcp` suffix) into MCP clients; OAuth completes
|
|
648
|
+
// against the bare path (so the client looks "connected") but the JSON-RPC POST
|
|
649
|
+
// then hits a path vault has no MCP handler for and 405s — a confusing
|
|
650
|
+
// "connected but erroring" half-state. We catch the bare-path POST here, BEFORE
|
|
651
|
+
// proxying, and 308-redirect to the canonical `<mount>/mcp`. 308 (vs 307)
|
|
652
|
+
// signals the redirect is permanent/cacheable, and like 307 it preserves the
|
|
653
|
+
// method + body, so a spec-compliant MCP client re-POSTs the JSON-RPC payload
|
|
654
|
+
// to the right endpoint and connects cleanly. Clients that DON'T follow
|
|
655
|
+
// redirects still get an actionable signal: the Location header + JSON body name
|
|
656
|
+
// the correct URL (vs the old opaque 405). Only the EXACT bare mount is caught —
|
|
657
|
+
// any sub-path (`<mount>/mcp`, `<mount>/api/...`, the Notes PWA) proxies through
|
|
658
|
+
// untouched, and only POST (the MCP transport verb) is redirected so a stray
|
|
659
|
+
// browser GET to the bare path keeps its existing proxy behavior.
|
|
660
|
+
if (req.method === "POST" && url.pathname === match.mount) {
|
|
661
|
+
const mcpUrl = `${match.mount}/mcp`;
|
|
662
|
+
const body = {
|
|
663
|
+
error: "missing_mcp_suffix",
|
|
664
|
+
message: `This is a Parachute vault path, not an MCP endpoint. Use ${mcpUrl} as your MCP server URL.`,
|
|
665
|
+
mcp_url: mcpUrl,
|
|
666
|
+
};
|
|
667
|
+
return new Response(JSON.stringify(body), {
|
|
668
|
+
status: 308,
|
|
669
|
+
headers: {
|
|
670
|
+
location: mcpUrl,
|
|
671
|
+
"content-type": "application/json",
|
|
672
|
+
// 308 is permanently cacheable by default; without no-store a client
|
|
673
|
+
// (or an intermediary) could cache the redirect and keep bouncing the
|
|
674
|
+
// bare path to `/mcp` even after a remount changes the routing. Same
|
|
675
|
+
// guard as the force-change-password redirect below.
|
|
676
|
+
"cache-control": "no-store",
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
646
680
|
// Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
|
|
647
681
|
// PARTY_FALLBACKS as a fallback source. No first-party vault fallback
|
|
648
682
|
// declares stripPrefix today (vault expects the full `/vault/<name>/*`
|
package/src/hub-settings.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* client to hit `/oauth/register` *within* that window is auto-approved
|
|
14
14
|
* (single-use, the value is cleared on consume). Past-due or absent
|
|
15
15
|
* means the standard pending-approval flow applies. Motivator: a
|
|
16
|
-
* canonical onboarding (install hub →
|
|
17
|
-
* authorize) shouldn't bounce the operator through a
|
|
18
|
-
* step they just set up the hub for.
|
|
16
|
+
* canonical onboarding (install hub → expose → wizard installs
|
|
17
|
+
* vault/surface → authorize) shouldn't bounce the operator through a
|
|
18
|
+
* manual approve step they just set up the hub for.
|
|
19
19
|
*
|
|
20
20
|
* Schema lives in `hub-db.ts` migration v7. This module is just the typed
|
|
21
21
|
* accessor — single-row reads/writes per key, no joins, no caching. The
|