@openparachute/hub 0.6.4-rc.8 → 0.6.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 +1 -1
- package/src/__tests__/expose-cloudflare.test.ts +103 -0
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/hub-server.test.ts +133 -0
- package/src/__tests__/hub-unit.test.ts +181 -0
- package/src/__tests__/init.test.ts +401 -0
- package/src/__tests__/install.test.ts +90 -0
- package/src/__tests__/migrate-cutover.test.ts +1 -0
- package/src/commands/expose-cloudflare.ts +28 -0
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +63 -1
- package/src/commands/install.ts +42 -1
- package/src/help.ts +1 -1
- package/src/hub-server.ts +34 -0
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +255 -0
|
@@ -341,6 +341,96 @@ describe("install", () => {
|
|
|
341
341
|
}
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
test("names the squatter holding the canonical port when the walk assigns a fallback (#590)", async () => {
|
|
345
|
+
// Field bug #590 item 2: a stale pre-supervisor vault zombie squats 1940;
|
|
346
|
+
// the install-time port walk silently routed to a fallback. Now it names the
|
|
347
|
+
// holder (pid + command line) + hints it may be a stale daemon. Detection
|
|
348
|
+
// only — never kills. Reuses the #581 pidOnPort / ownerOfPid seams.
|
|
349
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
350
|
+
try {
|
|
351
|
+
const logs: string[] = [];
|
|
352
|
+
const code = await install("vault", {
|
|
353
|
+
runner: async () => 0,
|
|
354
|
+
manifestPath: path,
|
|
355
|
+
configDir,
|
|
356
|
+
startService: async () => 0,
|
|
357
|
+
isLinked: () => false,
|
|
358
|
+
// Only vault's canonical 1940 is held → the walk picks a fallback in-range.
|
|
359
|
+
portProbe: async (p) => p === 1940,
|
|
360
|
+
// Inject the #581 seams: a foreign pid squats 1940.
|
|
361
|
+
pidOnPort: (p) => (p === 1940 ? 1234 : undefined),
|
|
362
|
+
ownerOfPid: (pid) => (pid === 1234 ? "bun /opt/vault/src/server.ts" : undefined),
|
|
363
|
+
log: (l) => logs.push(l),
|
|
364
|
+
});
|
|
365
|
+
expect(code).toBe(0);
|
|
366
|
+
const joined = logs.join("\n");
|
|
367
|
+
// The fallback warning still fires…
|
|
368
|
+
expect(joined).toMatch(/canonical port 1940 is in use; assigned/);
|
|
369
|
+
// …and now it NAMES the squatter + hints at a stale daemon.
|
|
370
|
+
expect(joined).toContain("pid 1234 (bun /opt/vault/src/server.ts)");
|
|
371
|
+
expect(joined).toMatch(/stale pre-supervisor daemon/);
|
|
372
|
+
expect(joined).toContain("kill 1234");
|
|
373
|
+
const entry = findService("parachute-vault", path);
|
|
374
|
+
expect(entry?.port).not.toBe(1940);
|
|
375
|
+
} finally {
|
|
376
|
+
cleanup();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("squatter pid present but command line unreadable → names the pid alone (#590)", async () => {
|
|
381
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
382
|
+
try {
|
|
383
|
+
const logs: string[] = [];
|
|
384
|
+
const code = await install("vault", {
|
|
385
|
+
runner: async () => 0,
|
|
386
|
+
manifestPath: path,
|
|
387
|
+
configDir,
|
|
388
|
+
startService: async () => 0,
|
|
389
|
+
isLinked: () => false,
|
|
390
|
+
portProbe: async (p) => p === 1940,
|
|
391
|
+
pidOnPort: (p) => (p === 1940 ? 4321 : undefined),
|
|
392
|
+
ownerOfPid: () => undefined, // ps failed / pid gone
|
|
393
|
+
log: (l) => logs.push(l),
|
|
394
|
+
});
|
|
395
|
+
expect(code).toBe(0);
|
|
396
|
+
const joined = logs.join("\n");
|
|
397
|
+
expect(joined).toContain("held by pid 4321.");
|
|
398
|
+
expect(joined).not.toContain("(undefined)");
|
|
399
|
+
} finally {
|
|
400
|
+
cleanup();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("no squatter naming when the canonical port is free (#590 — no false positive)", async () => {
|
|
405
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
406
|
+
try {
|
|
407
|
+
const logs: string[] = [];
|
|
408
|
+
let pidProbed = false;
|
|
409
|
+
const code = await install("vault", {
|
|
410
|
+
runner: async () => 0,
|
|
411
|
+
manifestPath: path,
|
|
412
|
+
configDir,
|
|
413
|
+
startService: async () => 0,
|
|
414
|
+
isLinked: () => false,
|
|
415
|
+
portProbe: async () => false, // canonical 1940 is free
|
|
416
|
+
pidOnPort: () => {
|
|
417
|
+
pidProbed = true;
|
|
418
|
+
return 9999;
|
|
419
|
+
},
|
|
420
|
+
ownerOfPid: () => "should-not-appear",
|
|
421
|
+
log: (l) => logs.push(l),
|
|
422
|
+
});
|
|
423
|
+
expect(code).toBe(0);
|
|
424
|
+
const joined = logs.join("\n");
|
|
425
|
+
// Canonical assigned → no fallback warning, no squatter probe at all.
|
|
426
|
+
expect(joined).not.toMatch(/is in use; assigned/);
|
|
427
|
+
expect(joined).not.toContain("should-not-appear");
|
|
428
|
+
expect(pidProbed).toBe(false);
|
|
429
|
+
} finally {
|
|
430
|
+
cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
344
434
|
test("`install lens` aliases to notes with a rename notice", async () => {
|
|
345
435
|
// Transition alias for the brief Notes→Lens rename (Apr 19) that was
|
|
346
436
|
// reverted on launch eve (Apr 22). Accepted for one release cycle so
|
|
@@ -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
|
}
|
|
@@ -16,15 +16,18 @@
|
|
|
16
16
|
* `expose-cloudflare.ts` (cloudflared) use so the two paths can't drift.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
19
20
|
import { readHubPort } from "../hub-control.ts";
|
|
20
21
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
21
22
|
import {
|
|
22
23
|
type EnsureHubUnitOpts,
|
|
23
24
|
type EnsureHubUnitResult,
|
|
25
|
+
type EnsureHubVersionMatchesResult,
|
|
24
26
|
HUB_UNIT_DEFAULT_PORT,
|
|
25
27
|
type HubUnitDeps,
|
|
26
28
|
defaultHubUnitDeps,
|
|
27
29
|
ensureHubUnit as ensureHubUnitImpl,
|
|
30
|
+
ensureHubVersionMatches as ensureHubVersionMatchesImpl,
|
|
28
31
|
} from "../hub-unit.ts";
|
|
29
32
|
import {
|
|
30
33
|
type DriveModuleOpDeps,
|
|
@@ -54,6 +57,17 @@ export interface ExposeSupervisorOpts {
|
|
|
54
57
|
hubUnitDeps?: HubUnitDeps;
|
|
55
58
|
/** Ensure the hub unit is up before / during expose (§3.2 / §4.3a). */
|
|
56
59
|
ensureHubUnit?: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Version-check-and-restart at the expose adoption point (#590). After the
|
|
62
|
+
* hub unit is confirmed up, compare the RUNNING hub's `/health` version to the
|
|
63
|
+
* installed package version; restart the managed unit on mismatch so an expose
|
|
64
|
+
* never wires a tunnel to a stale zombie. Production wires
|
|
65
|
+
* `ensureHubVersionMatches`; tests inject a stub.
|
|
66
|
+
*/
|
|
67
|
+
ensureHubVersion?: (ctx: {
|
|
68
|
+
port: number;
|
|
69
|
+
log: (line: string) => void;
|
|
70
|
+
}) => Promise<EnsureHubVersionMatchesResult>;
|
|
57
71
|
/** Drive a per-module op against the running hub (reads operator.token). */
|
|
58
72
|
driveModuleOp?: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
|
|
59
73
|
/**
|
|
@@ -83,6 +97,10 @@ export interface ExposeSupervisorOpts {
|
|
|
83
97
|
export interface ResolvedExposeSupervisor {
|
|
84
98
|
hubUnitDeps: HubUnitDeps;
|
|
85
99
|
ensureHubUnit: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
|
|
100
|
+
ensureHubVersion: (ctx: {
|
|
101
|
+
port: number;
|
|
102
|
+
log: (line: string) => void;
|
|
103
|
+
}) => Promise<EnsureHubVersionMatchesResult>;
|
|
86
104
|
driveModuleOp: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
|
|
87
105
|
openDb: (configDir: string) => import("bun:sqlite").Database;
|
|
88
106
|
selfHealOperatorTokenIssuer: (
|
|
@@ -105,6 +123,15 @@ export function resolveExposeSupervisor(
|
|
|
105
123
|
return {
|
|
106
124
|
hubUnitDeps,
|
|
107
125
|
ensureHubUnit: opts?.ensureHubUnit ?? ensureHubUnitImpl,
|
|
126
|
+
ensureHubVersion:
|
|
127
|
+
opts?.ensureHubVersion ??
|
|
128
|
+
((ctx) =>
|
|
129
|
+
ensureHubVersionMatchesImpl({
|
|
130
|
+
installedVersion: pkg.version,
|
|
131
|
+
port: ctx.port,
|
|
132
|
+
deps: hubUnitDeps,
|
|
133
|
+
log: ctx.log,
|
|
134
|
+
})),
|
|
108
135
|
driveModuleOp: opts?.driveModuleOp ?? driveModuleOpImpl,
|
|
109
136
|
openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
|
|
110
137
|
selfHealOperatorTokenIssuer:
|
|
@@ -145,6 +172,24 @@ export async function ensureHubUnitForExpose(
|
|
|
145
172
|
): Promise<{ ok: boolean; port: number }> {
|
|
146
173
|
const ensured = await sup.ensureHubUnit({ port, deps: sup.hubUnitDeps, log });
|
|
147
174
|
if (ensured.outcome === "already-up" || ensured.outcome === "started") {
|
|
175
|
+
// #590: the hub is up — but is it the version we installed? A zombie that
|
|
176
|
+
// merely answers /health must not become the target of a fresh tunnel.
|
|
177
|
+
// Compare + restart-on-mismatch (once). A non-unit-managed mismatch is NOT
|
|
178
|
+
// killed: surface it + fail the expose so the operator resolves it; a
|
|
179
|
+
// still-mismatched-after-restart (bun-linked branch) warns + continues.
|
|
180
|
+
try {
|
|
181
|
+
const versionResult = await sup.ensureHubVersion({ port: ensured.port, log });
|
|
182
|
+
for (const m of versionResult.messages) log(m);
|
|
183
|
+
if (
|
|
184
|
+
versionResult.outcome === "not-unit-managed" ||
|
|
185
|
+
versionResult.outcome === "restart-failed"
|
|
186
|
+
) {
|
|
187
|
+
return { ok: false, port: ensured.port };
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// A version-check failure must never block expose — degrade to a note.
|
|
191
|
+
log(`note: hub version check skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
192
|
+
}
|
|
148
193
|
return { ok: true, port: ensured.port };
|
|
149
194
|
}
|
|
150
195
|
for (const m of ensured.messages) log(m);
|
package/src/commands/init.ts
CHANGED
|
@@ -35,12 +35,18 @@
|
|
|
35
35
|
import { spawnSync } from "node:child_process";
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
38
39
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
39
40
|
import { type ExposeState, readExposeState } from "../expose-state.ts";
|
|
40
41
|
import { type EnsureHubOpts, HUB_DEFAULT_PORT, HUB_SVC, readHubPort } from "../hub-control.ts";
|
|
41
42
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
42
43
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
43
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type EnsureHubVersionMatchesResult,
|
|
46
|
+
ensureHubUnit,
|
|
47
|
+
ensureHubVersionMatches,
|
|
48
|
+
installAndStartHubUnit,
|
|
49
|
+
} from "../hub-unit.ts";
|
|
44
50
|
import { issueOperatorToken, readOperatorTokenFile } from "../operator-token.ts";
|
|
45
51
|
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
46
52
|
import { findService, readManifestLenient } from "../services-manifest.ts";
|
|
@@ -81,6 +87,18 @@ export interface InitOpts {
|
|
|
81
87
|
* Design §3.3 (init row), §4.1/§4.2, appendix (c).
|
|
82
88
|
*/
|
|
83
89
|
ensureHub?: (opts: EnsureHubOpts) => Promise<{ pid: number; port: number; started: boolean }>;
|
|
90
|
+
/**
|
|
91
|
+
* Test seam: version-check-and-restart at the hub adoption point (#590).
|
|
92
|
+
* After init confirms a hub is answering on the canonical port, it compares
|
|
93
|
+
* the RUNNING hub's `/health` version against this installed package version;
|
|
94
|
+
* on mismatch it restarts the managed unit (once) so a freshly-installed hub
|
|
95
|
+
* never adopts a stale zombie. Production wires `ensureHubVersionMatches`;
|
|
96
|
+
* tests stub it to assert the call without touching launchctl / the live hub.
|
|
97
|
+
*/
|
|
98
|
+
ensureHubVersion?: (ctx: {
|
|
99
|
+
port: number;
|
|
100
|
+
log: (line: string) => void;
|
|
101
|
+
}) => Promise<EnsureHubVersionMatchesResult>;
|
|
84
102
|
/**
|
|
85
103
|
* Test seam: guarantee an operator token exists once the hub is up (design
|
|
86
104
|
* §3.1 / §3.3). Production reads `operator.token`; if absent AND a hub user
|
|
@@ -582,6 +600,18 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
582
600
|
// spawn). The `ensureHub` seam is preserved for tests (and the return shape is
|
|
583
601
|
// unchanged); only the production default flipped.
|
|
584
602
|
const ensureHub = opts.ensureHub ?? defaultEnsureHubViaUnit;
|
|
603
|
+
// #590: after the hub is confirmed up, compare its RUNNING version to the
|
|
604
|
+
// installed package version and restart the managed unit on mismatch, so a
|
|
605
|
+
// freshly-installed hub never adopts a stale zombie that merely answers
|
|
606
|
+
// /health. Injectable for tests.
|
|
607
|
+
const ensureHubVersion =
|
|
608
|
+
opts.ensureHubVersion ??
|
|
609
|
+
((ctx) =>
|
|
610
|
+
ensureHubVersionMatches({
|
|
611
|
+
installedVersion: pkg.version,
|
|
612
|
+
port: ctx.port,
|
|
613
|
+
log: ctx.log,
|
|
614
|
+
}));
|
|
585
615
|
const guaranteeOperatorToken = opts.guaranteeOperatorToken ?? defaultGuaranteeOperatorToken;
|
|
586
616
|
const readExposeStateFn = opts.readExposeStateFn ?? (() => readExposeState());
|
|
587
617
|
const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
@@ -640,6 +670,38 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
640
670
|
// overridden, so the fallback is almost always correct.
|
|
641
671
|
if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
|
|
642
672
|
|
|
673
|
+
// Step 1.25 (#590): the hub answered /health, but is it the version we just
|
|
674
|
+
// installed? A zombie LaunchAgent survives `rm -rf ~/.parachute`, so a brand-
|
|
675
|
+
// new install can adopt month-old code that merely keeps the port. Compare the
|
|
676
|
+
// RUNNING version to the installed package version; on mismatch, restart the
|
|
677
|
+
// managed unit (once) so the tunnel/wizard/vault-install downstream bind to the
|
|
678
|
+
// NEW code. A non-unit-managed hub (legacy detached pid / dev `bun run serve`)
|
|
679
|
+
// is NOT killed — we surface the mismatch + an actionable message and bail so
|
|
680
|
+
// the operator decides. A still-mismatched-after-restart (bun-linked branch)
|
|
681
|
+
// warns + continues rather than looping.
|
|
682
|
+
try {
|
|
683
|
+
const versionResult = await ensureHubVersion({ port: hubPort, log });
|
|
684
|
+
for (const m of versionResult.messages) log(m);
|
|
685
|
+
if (versionResult.outcome === "not-unit-managed") {
|
|
686
|
+
// We can't safely take over a hub we don't own. Stop here so init doesn't
|
|
687
|
+
// wire a fresh tunnel + credentials to a stale runtime (the #590 field bug).
|
|
688
|
+
log("");
|
|
689
|
+
log("Resolve the version mismatch above, then re-run `parachute init`.");
|
|
690
|
+
return 1;
|
|
691
|
+
}
|
|
692
|
+
if (versionResult.outcome === "restart-failed") {
|
|
693
|
+
log("");
|
|
694
|
+
log("The hub service manager rejected the restart command.");
|
|
695
|
+
log("Try checking the logs:");
|
|
696
|
+
log(" parachute logs hub");
|
|
697
|
+
return 1;
|
|
698
|
+
}
|
|
699
|
+
// `match` / `not-running` / `restarted` / `still-mismatched` → continue.
|
|
700
|
+
} catch (err) {
|
|
701
|
+
// A version-check failure must never block init — degrade to a note.
|
|
702
|
+
log(`note: hub version check skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
703
|
+
}
|
|
704
|
+
|
|
643
705
|
// Step 1.5: guarantee an operator token exists (design §3.1 / §3.3). Under
|
|
644
706
|
// the unified model every per-module verb is an authenticated module-ops
|
|
645
707
|
// call, so the steady-state operator needs an `operator.token` on disk — the
|
package/src/commands/install.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { autoWireScribeAuth } from "../auto-wire.ts";
|
|
|
5
5
|
import { bunGlobalPrefixes, isLinked as defaultIsLinkedShared } from "../bun-link.ts";
|
|
6
6
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
7
7
|
import { type ExposeState, readExposeState } from "../expose-state.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
HUB_DEFAULT_PORT,
|
|
10
|
+
type PidOnPortFn,
|
|
11
|
+
defaultPidOnPort,
|
|
12
|
+
readHubPort,
|
|
13
|
+
} from "../hub-control.ts";
|
|
9
14
|
import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
|
|
10
15
|
import {
|
|
11
16
|
type ModuleManifest,
|
|
@@ -34,6 +39,7 @@ import {
|
|
|
34
39
|
type DisableStaleModuleUnitsResult,
|
|
35
40
|
disableStaleModuleUnits as defaultDisableStaleModuleUnits,
|
|
36
41
|
} from "../stale-module-units.ts";
|
|
42
|
+
import { type OwnerProbeFn, defaultOwnerOfPid } from "../supervisor.ts";
|
|
37
43
|
import { WELL_KNOWN_PATH } from "../well-known.ts";
|
|
38
44
|
import { type LifecycleOpts, start as lifecycleStart } from "./lifecycle.ts";
|
|
39
45
|
import { migrateNotice } from "./migrate.ts";
|
|
@@ -301,6 +307,22 @@ export interface InstallOpts {
|
|
|
301
307
|
* unless the test populates services.json directly.
|
|
302
308
|
*/
|
|
303
309
|
portProbe?: (port: number) => Promise<boolean>;
|
|
310
|
+
/**
|
|
311
|
+
* Test seam for the install-time port-squatter naming (#590 item 2). When the
|
|
312
|
+
* canonical port walk has to assign a fallback port because the canonical one
|
|
313
|
+
* is held, this looks up the pid LISTENing on the canonical port so the
|
|
314
|
+
* warning can name the holder (`pid 1234 (bun .../vault/src/server.ts)`) — the
|
|
315
|
+
* same #581 `pidOnPort` / `ownerOfPid` seams the supervisor start-path uses,
|
|
316
|
+
* reused (not duplicated). Detection-only — never kills. Production wires
|
|
317
|
+
* `defaultPidOnPort` (`lsof -ti :<port>`); tests inject a deterministic stub.
|
|
318
|
+
*/
|
|
319
|
+
pidOnPort?: PidOnPortFn;
|
|
320
|
+
/**
|
|
321
|
+
* Test seam for the install-time port-squatter naming (#590 item 2): the
|
|
322
|
+
* best-effort command line of the squatting pid. Production wires
|
|
323
|
+
* `defaultOwnerOfPid` (`ps -o command= -p <pid>`); tests inject a stub.
|
|
324
|
+
*/
|
|
325
|
+
ownerOfPid?: OwnerProbeFn;
|
|
304
326
|
/**
|
|
305
327
|
* Test seam for reading `<packageDir>/.parachute/module.json`. Production
|
|
306
328
|
* uses the real file reader; tests inject a map from package-dir → manifest
|
|
@@ -974,6 +996,25 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
974
996
|
});
|
|
975
997
|
if (portResult.warning) {
|
|
976
998
|
log(`⚠ ${portResult.warning}`);
|
|
999
|
+
// #590 item 2: the canonical port was held, so we walked to a fallback. Name
|
|
1000
|
+
// the squatter — the supervisor start-path does this post-#581; do it here at
|
|
1001
|
+
// install-time too. Reuse the #581 pidOnPort / ownerOfPid seams (detection
|
|
1002
|
+
// only; never kill). When the holder is a foreign pid (not one of OUR rows —
|
|
1003
|
+
// which is the common case when a stale pre-supervisor daemon is squatting),
|
|
1004
|
+
// surface its pid + command line + a hint.
|
|
1005
|
+
if (canonicalPort !== undefined && portResult.source !== "canonical") {
|
|
1006
|
+
const pidOnPort = opts.pidOnPort ?? defaultPidOnPort;
|
|
1007
|
+
const ownerOfPid = opts.ownerOfPid ?? defaultOwnerOfPid;
|
|
1008
|
+
const holder = pidOnPort(canonicalPort);
|
|
1009
|
+
if (holder !== undefined) {
|
|
1010
|
+
const cmdline = ownerOfPid(holder);
|
|
1011
|
+
const who = cmdline ? `pid ${holder} (${cmdline})` : `pid ${holder}`;
|
|
1012
|
+
log(` canonical port ${canonicalPort} is held by ${who}.`);
|
|
1013
|
+
log(
|
|
1014
|
+
` This may be a stale pre-supervisor daemon. If so, stop it (kill ${holder}) and re-run \`parachute install ${entryName}\` to reclaim the canonical port.`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
977
1018
|
}
|
|
978
1019
|
|
|
979
1020
|
// Find-or-seed the manifest entry. Re-read after the seed write so a silent
|
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
|