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