fix: audit fallout — tunnel port, orphaned dev processes, scrollback clear
Auditing for repeats of the `pnpm dev --tunnel` bugs turned up the same mistakes elsewhere. Remote tunnels assumed 4040. ProjectEngine's Cloudflare quick tunnel hardcoded http://localhost:4040, so a dashboard on an explicit --port, a PORT override, or runDashboard's EADDRINUSE rebind published a PUBLIC tunnel to whatever else held 4040. The dashboard now records its bound port (setLocalDashboardPort, from both runDashboard and headless serve) and the tunnel reads it, keeping 4040 only as the pre-report default. register-discovery-routes already derived its port from req.socket.localPort and is untouched. Stopping the dev wrapper orphaned everything it started. It installed no signal handlers, so teardown only ran from the child's close handler: signalling the wrapper left the dev server AND its cloudflared alive — observed twice, four surviving processes each time, including a public trycloudflare URL still serving a dev server believed to be down. Ctrl-C hid it by signalling the whole process group. SessionTerminal appended scrollback instead of clearing first, though the server sends it as a separate frame precisely so the client can clear. Latent today because every reattach builds a fresh xterm; a duplicated-history bug the moment an in-place reconnect appears. And BackupManager's centralDbPath is gone: written, never read, and a leftover of the removed SQLite backup — the same class of stale artifact that onboarding was using as evidence about a Postgres install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
45
packages/engine/src/__tests__/local-dashboard-port.test.ts
Normal file
45
packages/engine/src/__tests__/local-dashboard-port.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_PORT,
|
||||
getLocalDashboardPort,
|
||||
setLocalDashboardPort,
|
||||
resetLocalDashboardPortForTests,
|
||||
} from "../local-dashboard-port.js";
|
||||
|
||||
/*
|
||||
FNXC:RemoteAccess 2026-08-19-04:00:
|
||||
Remote tunnels targeted a hardcoded http://localhost:4040, so a dashboard on any other port —
|
||||
`--port`, a PORT override, or the EADDRINUSE rebind to an ephemeral port — published whatever ELSE
|
||||
owned 4040 under a URL the operator believed was theirs. The identical mistake in `pnpm dev
|
||||
--tunnel` published a container's own Fusion instead of the dev server.
|
||||
*/
|
||||
describe("local dashboard port", () => {
|
||||
beforeEach(() => {
|
||||
resetLocalDashboardPortForTests();
|
||||
});
|
||||
|
||||
it("falls back to the historical default before anything reports", () => {
|
||||
expect(getLocalDashboardPort()).toBe(DEFAULT_DASHBOARD_PORT);
|
||||
expect(DEFAULT_DASHBOARD_PORT).toBe(4040);
|
||||
});
|
||||
|
||||
it("returns the port the dashboard actually bound", () => {
|
||||
setLocalDashboardPort(51234);
|
||||
expect(getLocalDashboardPort()).toBe(51234);
|
||||
});
|
||||
|
||||
it("ignores values that cannot be a bound port", () => {
|
||||
setLocalDashboardPort(4041);
|
||||
for (const bogus of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
setLocalDashboardPort(bogus);
|
||||
// A bad report must never erase a good one, or the tunnel silently reverts to 4040.
|
||||
expect(getLocalDashboardPort()).toBe(4041);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the latest report, so a restart onto a new port is followed", () => {
|
||||
setLocalDashboardPort(4041);
|
||||
setLocalDashboardPort(51234);
|
||||
expect(getLocalDashboardPort()).toBe(51234);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../merge/merger-ai.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
import { setLocalDashboardPort, resetLocalDashboardPortForTests } from "../local-dashboard-port.js";
|
||||
import { NtfyNotifier } from "../util/notifier.js";
|
||||
import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthValidityLogger } from "../notification/index.js";
|
||||
|
||||
@@ -1233,6 +1234,7 @@ describe("ProjectEngine remote lifecycle quick tunnel mode", () => {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
// Nothing reported a port, so the historical default stands.
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
}),
|
||||
);
|
||||
@@ -1241,6 +1243,55 @@ describe("ProjectEngine remote lifecycle quick tunnel mode", () => {
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:RemoteAccess 2026-08-19-04:00:
|
||||
The target was hardcoded to 4040, so a dashboard on any other port — an explicit --port, a PORT
|
||||
override, or runDashboard's EADDRINUSE rebind to an ephemeral port — published a public tunnel to
|
||||
whatever ELSE owned 4040 (another Fusion, another app, or nothing). The dashboard reports its
|
||||
bound port and the tunnel must follow it.
|
||||
*/
|
||||
it("targets the port the dashboard actually bound", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(quickTunnelSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
|
||||
setLocalDashboardPort(51234);
|
||||
|
||||
try {
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await engine.startRemoteTunnel();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledWith(
|
||||
"cloudflare",
|
||||
expect.objectContaining({
|
||||
args: ["tunnel", "--url", "http://localhost:51234"],
|
||||
}),
|
||||
);
|
||||
|
||||
await engine.stop();
|
||||
} finally {
|
||||
resetLocalDashboardPortForTests();
|
||||
startSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces runtime prerequisite missing when cloudflared is unavailable in quick tunnel mode", async () => {
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
|
||||
@@ -8,6 +8,12 @@ export {
|
||||
export { reloadExemptTools, addToExemptTools, getExemptToolNames, evaluateAgentActionGate, resolveGateOutcome } from "./agents/agent-action-gate.js";
|
||||
export type { AgentActionGateContext, AgentActionGateDecision } from "./agents/agent-action-gate.js";
|
||||
export { createFusionAuthStorage, createFusionModelRegistry } from "./auth/auth-storage.js";
|
||||
export {
|
||||
DEFAULT_DASHBOARD_PORT,
|
||||
getLocalDashboardPort,
|
||||
setLocalDashboardPort,
|
||||
resetLocalDashboardPortForTests,
|
||||
} from "./local-dashboard-port.js";
|
||||
export {
|
||||
DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS,
|
||||
boundExistingModelRegistryRefresh,
|
||||
|
||||
38
packages/engine/src/local-dashboard-port.ts
Normal file
38
packages/engine/src/local-dashboard-port.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
FNXC:RemoteAccess 2026-08-19-04:00:
|
||||
The port this process's dashboard is actually serving on.
|
||||
|
||||
Remote tunnels used to point at a hardcoded `http://localhost:4040`. That is only correct when the
|
||||
dashboard happens to hold 4040: `fn dashboard --port`, a `PORT` override, or the EADDRINUSE path in
|
||||
runDashboard (which rebinds to an ephemeral port) all move it, and the tunnel then published
|
||||
whatever ELSE owned 4040 — another Fusion, another app, or nothing — under a URL the operator
|
||||
believes is theirs. The identical mistake in `pnpm dev --tunnel` published a container's own Fusion
|
||||
instead of the dev server, which is what made it worth hunting down here.
|
||||
|
||||
The dashboard records its bound port here as soon as it is listening; the engine reads it when
|
||||
building tunnel arguments. Same process in every shipping configuration (the dashboard route calls
|
||||
`engine.startRemoteTunnel()` in-process), so a module-scoped value is the whole mechanism. The 4040
|
||||
default only applies before anything has reported, which preserves the previous behaviour rather
|
||||
than inventing a new failure.
|
||||
*/
|
||||
|
||||
/** Port assumed when nothing has reported one — the historical dashboard default. */
|
||||
export const DEFAULT_DASHBOARD_PORT = 4040;
|
||||
|
||||
let reportedPort: number | undefined;
|
||||
|
||||
/** Record the port the dashboard is listening on. Called once the server is bound. */
|
||||
export function setLocalDashboardPort(port: number): void {
|
||||
if (!Number.isFinite(port) || port <= 0) return;
|
||||
reportedPort = Math.floor(port);
|
||||
}
|
||||
|
||||
/** The dashboard's reported port, or the historical default when it has not reported yet. */
|
||||
export function getLocalDashboardPort(): number {
|
||||
return reportedPort ?? DEFAULT_DASHBOARD_PORT;
|
||||
}
|
||||
|
||||
/** Test seam: forget any reported port. */
|
||||
export function resetLocalDashboardPortForTests(): void {
|
||||
reportedPort = undefined;
|
||||
}
|
||||
@@ -126,6 +126,7 @@ import { finalizeProvenAutoMergeTask } from "./merge/auto-merge-finalization.js"
|
||||
import { isTransientError } from "./errors/transient-error-detector.js";
|
||||
import { classifyTransientMergeError, MAX_AUTO_MERGE_TRANSIENT_RETRIES } from "./errors/transient-merge-error-classifier.js";
|
||||
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
|
||||
import { getLocalDashboardPort } from "./local-dashboard-port.js";
|
||||
import {
|
||||
deliverPostgresMigrationCompleteNoticeIfNeeded,
|
||||
deliverPostgresMigrationNoticeIfNeeded,
|
||||
@@ -2908,7 +2909,9 @@ export class ProjectEngine {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
// FNXC:RemoteAccess 2026-08-19-04:00: target the port the dashboard actually bound, not a
|
||||
// hardcoded 4040 that publishes whatever else happens to own it. See local-dashboard-port.
|
||||
args: ["tunnel", "--url", `http://localhost:${getLocalDashboardPort()}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user