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:
gsxdsm
2026-08-18 21:09:03 -07:00
parent b67e3aa8bc
commit 16e63462cc
14 changed files with 257 additions and 13 deletions

View 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);
});
});

View File

@@ -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,

View File

@@ -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,

View 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;
}

View File

@@ -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()}`],
},
};
}