From 6f461a47449e4cd8271c097f753207e42406cfcf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 20:16:44 -0700 Subject: [PATCH] fix: take the dev tunnel's token from the dev server, not a guessed file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner re-derived the token from ~/.fusion/settings.json. That is simply the wrong source: on a real run the file contained no daemonToken while the dashboard's own banner, two lines above, printed a working one — so the tunnel claimed no token existed next to a token that plainly did. The dashboard already holds the resolved token at the point where it reports its bound port, so it now reports both over the same IPC message and the wrapper prefers that over anything it could derive. The env/settings lookup survives only for targets that report nothing, such as an explicit --tunnel=PORT aimed at a server the dev child knows nothing about. The token crosses the existing parent/child channel only; it is never logged or sent onward. Co-Authored-By: Claude Opus 5 --- .changeset/dev-tunnel-token-from-child.md | 7 ++++ .../src/__tests__/dev-with-memory-lib.test.ts | 25 ++++++++++++++ packages/cli/src/commands/dashboard.ts | 19 +++++++++-- scripts/dev-with-memory-lib.mjs | 15 +++++++- scripts/dev-with-memory.mjs | 34 ++++++++++--------- scripts/lib/dev-tunnel.mjs | 11 +++++- 6 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 .changeset/dev-tunnel-token-from-child.md diff --git a/.changeset/dev-tunnel-token-from-child.md b/.changeset/dev-tunnel-token-from-child.md new file mode 100644 index 0000000000..9d8d8bb5ad --- /dev/null +++ b/.changeset/dev-tunnel-token-from-child.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: `pnpm dev --tunnel` now prints the dev server's real token instead of reporting none. +category: fix +dev: The tunnel banner re-derived the token by reading `~/.fusion/settings.json`, which is not a reliable source — on a real run that file held no `daemonToken` while the dashboard printed a working token two lines above, so the banner fell back to its `token-pending` wording. The dashboard now includes its resolved `dashboardAuthToken` in the `DEV_SERVER_LISTENING_MESSAGE` IPC report alongside the bound port, and `resolveDevTunnelAuth` prefers that `reportedToken` over the env/file lookup, which remains only for targets that report nothing (an explicit `--tunnel=PORT`). The token crosses the existing parent/child IPC channel only; it is never logged or forwarded. diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 4a977624a8..bdd6bb1812 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -6,6 +6,7 @@ import { getPrebuildCommand, normalizePrebuildMode, parseDevWrapperArgs, + readDevServerListening, readDevServerListeningPort, resolveDevTunnelPort, resolvePrebuildMode, @@ -427,6 +428,30 @@ describe("development source restart watcher", () => { expect(readDevServerListeningPort("fusion:dev-server-listening")).toBeNull(); }); + /* + FNXC:DevTunnel 2026-08-19-03:00: + The token must come from the child, not be re-derived. A real run proved the derivation wrong: + ~/.fusion/settings.json held no daemonToken, so the tunnel banner said "no token yet" while the + dashboard's own banner two lines above printed a working one. + */ + it("reads the port and token the dev server reports", () => { + expect(readDevServerListening({ type: "fusion:dev-server-listening", port: 58635, token: "fn_real" })) + .toEqual({ port: 58635, token: "fn_real" }); + // A no-auth dev server reports no token; that is not a malformed report. + expect(readDevServerListening({ type: "fusion:dev-server-listening", port: 58635 })) + .toEqual({ port: 58635, token: null }); + expect(readDevServerListening({ type: "fusion:dev-source-restart-armed", port: 1 })).toBeNull(); + expect(readDevServerListening({ type: "fusion:dev-server-listening", port: 0 })).toBeNull(); + }); + + it("prefers the reported token over anything it could derive", () => { + expect(auth({ reportedToken: "fn_from_child", env: { FUSION_DASHBOARD_TOKEN: "fn_env" }, readToken: () => "fn_disk" })) + .toEqual({ kind: "token", token: "fn_from_child" }); + // Falls back only when nothing was reported (e.g. an explicit --tunnel=PORT never waits). + expect(auth({ reportedToken: null, env: { FUSION_DASHBOARD_TOKEN: "fn_env" } })) + .toEqual({ kind: "token", token: "fn_env" }); + }); + it("still treats a dev dashboard on an ephemeral port as token-gated", () => { // The reported port IS the dashboard, so the banner must keep lending it the token rather // than classifying it "foreign" for not matching 4040. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index a61144dc81..d33597cd51 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2964,9 +2964,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard"); } - // FNXC:DevTunnel 2026-08-19-02:05: report the REAL port to the dev supervisor (no-op without an - // IPC channel, i.e. every non-`pnpm dev` launch). See DEV_SERVER_LISTENING_MESSAGE. - process.send?.({ type: DEV_SERVER_LISTENING_MESSAGE, port: actualPort, host: selectedHost }); + /* + FNXC:DevTunnel 2026-08-19-02:05: report the REAL port to the dev supervisor (no-op without an + IPC channel, i.e. every non-`pnpm dev` launch). See DEV_SERVER_LISTENING_MESSAGE. + + FNXC:DevTunnel 2026-08-19-03:00: report the resolved auth token too. The supervisor previously + re-derived it by reading ~/.fusion/settings.json, which is simply the wrong source — the token + is not necessarily stored there, so `--tunnel` printed "no token yet" while the dashboard's own + banner printed a working one two lines above. This value IS the token the server installed, so + there is nothing left to guess. IPC only, parent process only: never logged, never sent onward. + */ + process.send?.({ + type: DEV_SERVER_LISTENING_MESSAGE, + port: actualPort, + host: selectedHost, + token: dashboardAuthToken, + }); // ── mDNS discovery: broadcast presence and listen for other nodes ─────── // diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index a7f12d0c1d..bb9f3ebfd3 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -213,10 +213,23 @@ export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening"; /** Port from a dev child's listening report, or null for any other message. */ export function readDevServerListeningPort(message) { + return readDevServerListening(message)?.port ?? null; +} + +/** + * The dev child's listening report: the port it actually bound and the auth token it installed. + * + * FNXC:DevTunnel 2026-08-19-03:00: the token comes from the child because the supervisor cannot + * derive it — reading ~/.fusion/settings.json found nothing on a real run while the dashboard had a + * perfectly good token in memory, so `--tunnel` printed "no token yet" next to a working banner. + */ +export function readDevServerListening(message) { if (!message || typeof message !== "object") return null; if (message.type !== DEV_SERVER_LISTENING_MESSAGE) return null; const port = Number(message.port); - return Number.isInteger(port) && port > 0 ? port : null; + if (!Number.isInteger(port) || port <= 0) return null; + const token = typeof message.token === "string" && message.token.length > 0 ? message.token : null; + return { port, token }; } /** diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 2b3ea83213..db90936415 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -14,7 +14,7 @@ import { createDevWatchRestartCoordinator, getPrebuildCommand, parseDevWrapperArgs, - readDevServerListeningPort, + readDevServerListening, resolveDevTunnelPort, resolvePrebuildMode, } from "./dev-with-memory-lib.mjs"; @@ -110,30 +110,30 @@ nothing about), so it is used immediately and never waits. If the report never a still comes up on the configured port, since a mis-targeted preview beats no preview at all. */ const DEV_SERVER_PORT_REPORT_TIMEOUT_MS = 60_000; -let reportDevServerPort; -const devServerPortReport = new Promise((resolve) => { reportDevServerPort = resolve; }); - -async function resolveTunnelTargetPort() { - if (tunnelPort) return { port: tunnelPort, source: "explicit" }; +let reportDevServerListening; +const devServerListeningReport = new Promise((resolve) => { reportDevServerListening = resolve; }); +async function resolveTunnelTarget() { const configured = resolveDevTunnelPort(undefined); + if (tunnelPort) return { port: tunnelPort, token: null, source: "explicit" }; + const timeout = new Promise((resolve) => { setTimeout(() => resolve(null), DEV_SERVER_PORT_REPORT_TIMEOUT_MS).unref?.(); }); - const reported = await Promise.race([devServerPortReport, timeout]); + const reported = await Promise.race([devServerListeningReport, timeout]); - if (reported == null) { + if (!reported) { console.warn(`[fusion:dev] dev server never reported its port — tunnelling ${configured}, which may not be it`); - return { port: configured, source: "assumed" }; + return { port: configured, token: null, source: "assumed" }; } - if (reported !== configured) { - console.log(`[fusion:dev] dev server bound ${reported} (not ${configured}) — tunnelling ${reported}`); + if (reported.port !== configured) { + console.log(`[fusion:dev] dev server bound ${reported.port} (not ${configured}) — tunnelling ${reported.port}`); } - return { port: reported, source: "reported" }; + return { port: reported.port, token: reported.token, source: "reported" }; } async function openDevTunnel() { - const { port, source } = await resolveTunnelTargetPort(); + const { port, token, source } = await resolveTunnelTarget(); /* FNXC:DevTunnel 2026-08-19-02:05: A port the CHILD reported is the dev dashboard by definition, whatever number it landed on — so it @@ -147,7 +147,9 @@ async function openDevTunnel() { Resolved at print time (not at parse time) so the token the dev child mints on a first authenticated run is already on disk by the time the banner needs it. */ - const auth = resolveDevTunnelAuth({ port, dashboardPort, args: forwardedArgs }); + // FNXC:DevTunnel 2026-08-19-03:00: the child's own token wins; the settings/env lookup is only a + // fallback for targets that never reported one (an explicit --tunnel=PORT). + const auth = resolveDevTunnelAuth({ port, dashboardPort, args: forwardedArgs, reportedToken: token }); devTunnel = await startDevTunnel({ port, auth }); } @@ -189,8 +191,8 @@ function runApp(extraArgs) { } watchRestart.attach(tsx); tsx.on("message", (message) => { - const listeningPort = readDevServerListeningPort(message); - if (listeningPort) reportDevServerPort(listeningPort); + const listening = readDevServerListening(message); + if (listening) reportDevServerListening(listening); watchRestart.onMessage(message); }); ensureSourceWatcher(); diff --git a/scripts/lib/dev-tunnel.mjs b/scripts/lib/dev-tunnel.mjs index 5eefd3e3c4..2a71bcbd71 100644 --- a/scripts/lib/dev-tunnel.mjs +++ b/scripts/lib/dev-tunnel.mjs @@ -158,11 +158,20 @@ export function resolveDevTunnelAuth({ env = process.env, settingsFile = resolveGlobalSettingsFile(), readToken = readStoredDaemonToken, + reportedToken = null, } = {}) { if (port !== dashboardPort) return { kind: "foreign" }; if (args.includes("--no-auth")) return { kind: "no-auth" }; - const token = env.FUSION_DASHBOARD_TOKEN + /* + FNXC:DevTunnel 2026-08-19-03:00: + `reportedToken` is the token the dev server actually installed, handed over its IPC channel. It + wins over every derived source because deriving was wrong: on a real run the token was not in + ~/.fusion/settings.json at all, so the banner claimed none existed while the dashboard printed a + working one directly above it. The env/file lookup remains for targets that report nothing. + */ + const token = reportedToken + ?? env.FUSION_DASHBOARD_TOKEN ?? env.FUSION_DAEMON_TOKEN ?? readToken(settingsFile);