diff --git a/.changeset/fn-004-remote-token-auth.md b/.changeset/fn-004-remote-token-auth.md new file mode 100644 index 0000000000..f8e04ab9a4 --- /dev/null +++ b/.changeset/fn-004-remote-token-auth.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make freshly generated Remote Access links authenticate immediately. +category: fix +dev: Synchronizes global settings cache reads used by remote-login handoff. diff --git a/docs/remote-access.md b/docs/remote-access.md index b5e6d0e3ce..e36bf3c40a 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -287,8 +287,10 @@ Treat these links as secrets. Fusion supports two token modes for remote login handoff: 1. **Persistent token** (`remoteAccess.tokenStrategy.persistent`) - - Stored in project settings. + - Stored in global settings. - Reused across generated links until regenerated. + - A token minted, rotated, or saved by any Remote Access surface takes effect immediately at `GET /remote-login` in the running process, including when dashboard daemon authentication is enabled. + - The main Settings form intentionally does not send a persistent-token field; saving other Remote Access fields preserves the existing persistent token. - `GET /api/remote/settings` returns a **masked** representation only. 2. **Short-lived token** (`remoteAccess.tokenStrategy.shortLived`) @@ -320,7 +322,7 @@ Recommended usage by risk level: | Tunnel remains `stopped` after provider switch | Provider was activated but tunnel start was never requested | `GET /api/remote/status` and confirm no start request was made | Run explicit start (`POST /api/remote/tunnel/start`) after activation | | `GET /remote-login?rt=` returns `401` `remote_token_missing` | Missing `rt` query token | Validate URL structure includes `?rt=` | Regenerate/fetch URL via `/api/remote-access/auth/login-url`, `/api/remote/url`, or `/api/remote/qr` | | `GET /remote-login?rt=` returns `401` `remote_token_expired` | Short-lived token expired | Check `expiresAt` from generation response and local clock drift | Generate a new short-lived token/login URL and retry | -| `GET /remote-login?rt=` returns `401` `remote_token_invalid` | Wrong/rotated token or disabled token strategy | Confirm token mode and whether persistent token was regenerated | Re-fetch a current URL; if needed re-enable token strategy and rotate token | +| `GET /remote-login?rt=` returns `401` `remote_token_invalid` | Wrong/rotated token, disabled strategy, or an out-of-date server process | Confirm token mode, provider/strategy state, and whether the dashboard was updated | Re-fetch a current URL; if the main dashboard token works but a freshly generated remote link still fails, restart into the current Fusion build and report the issue with the route response code | | Restart does not restore prior running tunnel even with remember enabled | Restore gates failed or stale marker reconciled | Inspect `/api/remote/status.restore` (`outcome`, `reason`, optional `message`) | Resolve reported reason (`provider_not_configured`, `runtime_prerequisite_missing`, etc.), then start manually | | Dashboard and headless behavior appear different | Different runtime/auth context (project selection, bearer token, host) | Confirm same project config and same endpoint calls in both modes | Use `/api/remote/status` and `/api/remote/settings` to compare canonical state; align auth/token/project context | diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 157c1457a8..86da5b3717 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { GlobalSettingsStore, defaultGlobalDir } from "../config/global-settings.js"; import { DEFAULT_GLOBAL_SETTINGS } from "../types.js"; import { readFile, rm, writeFile, mkdir } from "node:fs/promises"; @@ -959,5 +959,44 @@ describe("GlobalSettingsStore", () => { expect(raw.defaultProvider).toBe("anthropic"); expect(raw.defaultModelId).toBeUndefined(); }); + + it("discards a snapshot read concurrently with a sibling write", async () => { + const sibling = new GlobalSettingsStore(dir); + await store.init(); + let releaseRead!: () => void; + const readStarted = new Promise((resolve) => { + const originalReadRaw = sibling.readRaw.bind(sibling); + vi.spyOn(sibling, "readRaw").mockImplementationOnce(async () => { + resolve(); + await new Promise((release) => { releaseRead = release; }); + return originalReadRaw(); + }); + }); + + const pendingRead = sibling.getSettings(); + await readStarted; + await store.updateSettings({ themeMode: "dark" }); + releaseRead(); + + expect((await pendingRead).themeMode).toBe("dark"); + expect((await sibling.getSettings()).themeMode).toBe("dark"); + }); + + it("refreshes a primed sibling store after a write and preserves its latest snapshot", async () => { + const sibling = new GlobalSettingsStore(dir); + await store.init(); + await sibling.getSettings(); + await store.updateSettings({ themeMode: "dark", futureSetting: "preserve-me" }); + + expect((await sibling.getSettings()).themeMode).toBe("dark"); + await sibling.updateSettings({ colorTheme: "velvet" }); + + const raw = await sibling.readRaw(); + expect(raw).toMatchObject({ themeMode: "dark", colorTheme: "velvet", futureSetting: "preserve-me" }); + await store.invalidateCache(); + // @ts-expect-error null intentionally removes an unknown key. + await sibling.updateSettings({ futureSetting: null }); + expect((await store.getSettings() as Record).futureSetting).toBeUndefined(); + }); }); }); diff --git a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts index ed4e3cf73f..dd9e60d8b7 100644 --- a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts +++ b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts @@ -13,6 +13,7 @@ import { } from "../../__test-utils__/pg-test-harness.js"; import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js"; import { sql } from "drizzle-orm"; +import { DEFAULT_GLOBAL_SETTINGS } from "../../types.js"; const credentialLaneKeys = [ ["defaultProvider", "defaultCredentialInstanceId"], @@ -45,6 +46,42 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => { expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); }); + it("deep-merges main remote settings patches without clearing a persistent token", async () => { + const store = h.store(); + const persistent = { enabled: true, token: "frt_preserved_persistent_token" }; + await store.updateGlobalSettings({ + remoteAccess: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess, + activeProvider: "cloudflare", + providers: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.providers, + cloudflare: { ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.providers.cloudflare, enabled: true }, + }, + tokenStrategy: { ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.tokenStrategy, persistent }, + }, + }); + + // This matches buildRemoteAccessPatch: it intentionally omits persistent. + await store.updateGlobalSettings({ + remoteAccess: { + activeProvider: "tailscale", + providers: { tailscale: { enabled: true, hostname: "tail.example.test" } }, + tokenStrategy: { shortLived: { enabled: true, ttlMs: 120_000 } }, + lifecycle: { rememberLastRunning: true }, + }, + } as never); + + const updated = (await store.getSettings()).remoteAccess!; + expect(updated.tokenStrategy.persistent).toEqual(persistent); + expect(updated.activeProvider).toBe("tailscale"); + expect(updated.providers.tailscale).toMatchObject({ enabled: true, hostname: "tail.example.test" }); + expect(updated.tokenStrategy.shortLived).toMatchObject({ enabled: true, ttlMs: 120_000 }); + expect(updated.lifecycle).toMatchObject({ rememberLastRunning: true }); + + await store.updateGlobalSettings({ remoteAccess: null }); + expect((await store.getSettings()).remoteAccess).toBeUndefined(); + }); + it("persists project-level settings via updateSettings", async () => { const store = h.store(); await store.updateSettings({ diff --git a/packages/core/src/config/global-settings.ts b/packages/core/src/config/global-settings.ts index 7b496ec679..0e7a99b3fa 100644 --- a/packages/core/src/config/global-settings.ts +++ b/packages/core/src/config/global-settings.ts @@ -34,6 +34,19 @@ remain layerless; production always initializes the central PostgreSQL layer. */ const directGlobalRevisionLayers = new Map>(); +/** Process-local settings-file generations keep independently constructed stores coherent. */ +const settingsCacheEpochs = new Map(); + +function getSettingsCacheEpoch(settingsPath: string): number { + return settingsCacheEpochs.get(settingsPath) ?? 0; +} + +function bumpSettingsCacheEpoch(settingsPath: string): number { + const next = getSettingsCacheEpoch(settingsPath) + 1; + settingsCacheEpochs.set(settingsPath, next); + return next; +} + function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); } @@ -152,6 +165,7 @@ export class GlobalSettingsStore { /** Write-through cache for settings. Invalidated on every updateSettings() call. */ private cachedSettings: GlobalSettings | null = null; + private cachedSettingsEpoch = 0; /** Promise chain for serializing read-modify-write cycles */ private lock: Promise = Promise.resolve(); @@ -199,6 +213,7 @@ export class GlobalSettingsStore { await mkdir(this.dir, { recursive: true }); if (!existsSync(this.settingsPath)) { await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS); + bumpSettingsCacheEpoch(this.settingsPath); return true; } return false; @@ -248,12 +263,27 @@ export class GlobalSettingsStore { * If the file doesn't exist or is invalid, returns defaults without throwing. */ async getSettings(): Promise { - if (this.cachedSettings !== null) { + for (;;) { + const currentEpoch = getSettingsCacheEpoch(this.settingsPath); + if (this.cachedSettings !== null && this.cachedSettingsEpoch === currentEpoch) { + return this.cachedSettings; + } + + const parsed = await this.readRaw(); + if (getSettingsCacheEpoch(this.settingsPath) !== currentEpoch) { + /* + FNXC:RemoteAccessAuth 2026-08-18-07:06: + A remote-token writer can finish while another store is reading settings. + Retry instead of caching the pre-write snapshot at the new generation, + because /remote-login must immediately accept the freshly minted token. + */ + continue; + } + + this.cachedSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...parsed } as GlobalSettings; + this.cachedSettingsEpoch = currentEpoch; return this.cachedSettings; } - const parsed = await this.readRaw(); - this.cachedSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...parsed } as GlobalSettings; - return this.cachedSettings; } /** @@ -330,7 +360,14 @@ export class GlobalSettingsStore { await mkdir(this.dir, { recursive: true }); await this.atomicWrite(withDefaults); } + /* + FNXC:RemoteAccessAuth 2026-08-18-06:49: + Remote-token writers and /remote-login use separate GlobalSettingsStore + instances. Publish a settings-file generation after the atomic write so a + primed reader cannot reject a newly minted token or write it back stale. + */ this.cachedSettings = withDefaults; + this.cachedSettingsEpoch = bumpSettingsCacheEpoch(this.settingsPath); return this.cachedSettings; }); } @@ -392,6 +429,7 @@ export class GlobalSettingsStore { */ invalidateCache(): void { this.cachedSettings = null; + this.cachedSettingsEpoch = bumpSettingsCacheEpoch(this.settingsPath); } // ── Private helpers ───────────────────────────────────────────── diff --git a/packages/dashboard/app/__tests__/auth-token-capture.test.ts b/packages/dashboard/app/__tests__/auth-token-capture.test.ts new file mode 100644 index 0000000000..13339dd4d0 --- /dev/null +++ b/packages/dashboard/app/__tests__/auth-token-capture.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadAuthModule() { + vi.resetModules(); + return import("../auth"); +} + +describe("remote login token capture", () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + it("replaces an existing dashboard token with the remote-login handoff token", async () => { + window.localStorage.setItem("fn.authToken", "old-dashboard-token"); + window.history.replaceState({}, "", "/?token=remote-daemon-token"); + const { getAuthToken, installAuthFetch } = await loadAuthModule(); + const fetch = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal("fetch", fetch); + + expect(getAuthToken()).toBe("remote-daemon-token"); + expect(window.localStorage.getItem("fn.authToken")).toBe("remote-daemon-token"); + installAuthFetch(); + await window.fetch("/api/settings"); + + expect(fetch).toHaveBeenCalledWith("/api/settings", expect.objectContaining({ + headers: expect.any(Headers), + })); + expect((fetch.mock.calls[0]?.[1] as RequestInit).headers as Headers).toHaveProperty("get"); + expect(((fetch.mock.calls[0]?.[1] as RequestInit).headers as Headers).get("Authorization")).toBe("Bearer remote-daemon-token"); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index e3dc4e5fdc..2b21097f89 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -524,6 +524,10 @@ describe("splitSettingsSave", () => { }); expect(projectPatch).toEqual({}); + expect(globalPatch.remoteAccess?.tokenStrategy).toEqual({ + shortLived: expect.any(Object), + }); + expect(JSON.stringify(globalPatch.remoteAccess)).not.toContain("persistent"); expect(globalPatch).toEqual({ remoteAccess: expect.objectContaining({ activeProvider: "tailscale", diff --git a/packages/dashboard/src/__tests__/remote-login-handoff.test.ts b/packages/dashboard/src/__tests__/remote-login-handoff.test.ts new file mode 100644 index 0000000000..5a2891e36e --- /dev/null +++ b/packages/dashboard/src/__tests__/remote-login-handoff.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_GLOBAL_SETTINGS, GlobalSettingsStore, type Settings, type TaskStore } from "@fusion/core"; +import { createServer } from "../server.js"; +import { request } from "../test-request.js"; + +const daemonToken = "fn_test_daemon_token"; + +function remoteAccess(token: string) { + return { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess, + activeProvider: "cloudflare" as const, + providers: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.providers, + cloudflare: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.providers.cloudflare, + enabled: true, + ingressUrl: "https://remote.example.test", + }, + }, + tokenStrategy: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.tokenStrategy, + persistent: { + ...DEFAULT_GLOBAL_SETTINGS.remoteAccess.tokenStrategy.persistent, + enabled: true, + token, + }, + }, + }; +} + +class RemoteLoginStore extends EventEmitter { + constructor(private readonly globalSettings: GlobalSettingsStore) { super(); } + getRootDir() { return process.cwd(); } + getFusionDir() { return `${process.cwd()}/.fusion`; } + getSettings = vi.fn(async (): Promise => this.globalSettings.getSettings() as Promise); + getSettingsFast = this.getSettings; + updateGlobalSettings = vi.fn(async (patch: Record) => this.globalSettings.updateSettings(patch)); + getGlobalSettingsStore = () => this.globalSettings; + getAsyncLayer = vi.fn(() => ({ db: { update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => []) })) })) })) } })); + getProjectScopedPluginMcpServers = vi.fn().mockResolvedValue([]); + getTaskWorkflowSelection = vi.fn(); + getWorkflowDefinition = vi.fn(async () => undefined); + getWorkflowSettingValues = vi.fn(() => ({})); + getWorkflowSettingsProjectId = vi.fn(() => "remote-login-handoff"); +} + +async function apiRequest(app: ReturnType, method: string, path: string, body?: unknown) { + return request( + app, + method, + path, + body === undefined ? undefined : JSON.stringify(body), + { + Authorization: `Bearer ${daemonToken}`, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ); +} + +function createInertOptions() { + return { + chatStore: Object.assign(new EventEmitter(), { deleteSessionsForAgentId: vi.fn().mockResolvedValue(undefined) }) as never, + aiSessionStore: Object.assign(new EventEmitter(), { + recoverStaleSessions: vi.fn().mockResolvedValue(undefined), rehydrateFromStore: vi.fn().mockResolvedValue(0), + stopScheduledCleanup: vi.fn(), cleanupStaleSessions: vi.fn().mockResolvedValue({ terminalDeleted: 0, orphanedDeleted: 0 }), + }) as never, + }; +} + +function tokenFromLoginUrl(value: unknown): string { + return new URL((value as { url: string }).url).searchParams.get("rt")!; +} + +async function bootRemoteServer(input: { + token?: string; + daemon?: { token: string }; + useEnvironmentToken?: boolean; + noAuth?: boolean; + activeProvider?: "cloudflare" | "tailscale"; +} = {}) { + const dir = await mkdtemp(join(tmpdir(), "fusion-remote-login-")); + const settings = new GlobalSettingsStore(dir); + await settings.init(); + const configured = remoteAccess(input.token ?? "seed-token"); + if (input.activeProvider === "tailscale") { + configured.activeProvider = "tailscale"; + configured.providers.tailscale = { + ...configured.providers.tailscale, + enabled: true, + hostname: "remote.example.test", + }; + } + await settings.updateSettings({ remoteAccess: configured }); + const app = createServer(new RemoteLoginStore(settings) as unknown as TaskStore, { + ...(input.noAuth ? { noAuth: true } : input.useEnvironmentToken ? {} : { daemon: input.daemon ?? { token: daemonToken } }), + ...createInertOptions(), + }); + return { app, dir, settings }; +} + +describe("remote-login global settings handoff", () => { + const dirs: string[] = []; + afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); + + it("accepts remote URLs minted by URL, QR, and login-url routes", async () => { + const { app, dir } = await bootRemoteServer({ token: "" }); + dirs.push(dir); + + const url = await apiRequest(app, "GET", "/api/remote/url"); + const qr = await apiRequest(app, "GET", "/api/remote/qr"); + const loginUrl = await apiRequest(app, "POST", "/api/remote-access/auth/login-url", { mode: "persistent" }); + for (const payload of [url.body, qr.body, { url: (loginUrl.body as { loginUrl: string }).loginUrl }]) { + const handoff = await request(app, "GET", `/remote-login?rt=${tokenFromLoginUrl(payload)}`); + expect(handoff.status).toBe(302); + expect(handoff.headers.location).toBe(`/?token=${daemonToken}`); + } + }); + + it("accepts a regenerated token and rejects its rotated-away predecessor", async () => { + const { app, dir } = await bootRemoteServer({ token: "old-token" }); + dirs.push(dir); + + const regenerated = await apiRequest(app, "POST", "/api/remote/token/persistent/regenerate", {}); + const nextToken = (regenerated.body as { token: string }).token; + expect((await request(app, "GET", `/remote-login?rt=${nextToken}`)).status).toBe(302); + const old = await request(app, "GET", "/remote-login?rt=old-token"); + expect(old.status).toBe(401); + expect(old.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" }); + }); + + it("keeps a minted token through remote panel saves and provider activation", async () => { + const { app, dir, settings } = await bootRemoteServer({ token: "minted-token" }); + dirs.push(dir); + const initial = await settings.getSettings(); + await settings.updateSettings({ + remoteAccess: { + ...initial.remoteAccess!, + providers: { ...initial.remoteAccess!.providers, tailscale: { ...initial.remoteAccess!.providers.tailscale, enabled: true } }, + }, + }); + + expect((await apiRequest(app, "PUT", "/api/remote/settings", { remoteShortLivedEnabled: true })).status).toBe(200); + expect((await apiRequest(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" })).status).toBe(200); + const handoff = await request(app, "GET", "/remote-login?rt=minted-token"); + expect(handoff.status).toBe(302); + }); + + it("handles short-lived, missing, disabled, no-auth, and environment-daemon handoffs", async () => { + const { app, dir, settings } = await bootRemoteServer({ token: "persistent-token" }); + dirs.push(dir); + const initial = await settings.getSettings(); + await settings.updateSettings({ remoteAccess: { ...initial.remoteAccess!, tokenStrategy: { ...initial.remoteAccess!.tokenStrategy, shortLived: { ...initial.remoteAccess!.tokenStrategy.shortLived, enabled: true } } } }); + const shortLived = await apiRequest(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 60_000 }); + const token = (shortLived.body as { token: string }).token; + expect((await request(app, "GET", `/remote-login?rt=${token}`)).status).toBe(302); + vi.useFakeTimers(); + vi.advanceTimersByTime(60_001); + const expired = await request(app, "GET", `/remote-login?rt=${token}`); + vi.useRealTimers(); + expect(expired.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" }); + expect((await request(app, "GET", "/remote-login")).body).toEqual({ error: "Unauthorized", code: "remote_token_missing" }); + + const configured = await settings.getSettings(); + await settings.updateSettings({ remoteAccess: { ...configured.remoteAccess!, tokenStrategy: { ...configured.remoteAccess!.tokenStrategy, persistent: { ...configured.remoteAccess!.tokenStrategy.persistent, enabled: false } } } }); + expect((await request(app, "GET", "/remote-login?rt=persistent-token")).body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" }); + const disabled = await settings.getSettings(); + await settings.updateSettings({ remoteAccess: { ...disabled.remoteAccess!, providers: { ...disabled.remoteAccess!.providers, cloudflare: { ...disabled.remoteAccess!.providers.cloudflare, enabled: false } } } }); + expect((await request(app, "GET", "/remote-login?rt=persistent-token")).body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" }); + + const noAuth = await bootRemoteServer({ token: "no-auth-token", noAuth: true }); + dirs.push(noAuth.dir); + expect((await request(noAuth.app, "GET", "/remote-login?rt=no-auth-token")).headers.location).toBe("/"); + + const previous = process.env.FUSION_DAEMON_TOKEN; + process.env.FUSION_DAEMON_TOKEN = "environment-daemon-token"; + try { + const env = await bootRemoteServer({ token: "env-token", useEnvironmentToken: true }); + dirs.push(env.dir); + expect((await request(env.app, "GET", "/remote-login?rt=env-token")).headers.location).toBe("/?token=environment-daemon-token"); + } finally { + if (previous === undefined) delete process.env.FUSION_DAEMON_TOKEN; + else process.env.FUSION_DAEMON_TOKEN = previous; + } + }); + + it("accepts a token rotated through a separately cached global settings store", async () => { + const dir = await mkdtemp(join(tmpdir(), "fusion-remote-login-")); + dirs.push(dir); + const serverSettings = new GlobalSettingsStore(dir); + const routeSettings = new GlobalSettingsStore(dir); + await serverSettings.init(); + await serverSettings.updateSettings({ remoteAccess: remoteAccess("old-token") }); + await serverSettings.getSettings(); + await routeSettings.updateSettings({ remoteAccess: remoteAccess("new-token") }); + + /* + FNXC:RemoteAccessAuth 2026-08-18-06:49: + A token minted by a remote-access surface must authenticate in this process + even when the server TaskStore primed its own global-settings cache first. + */ + const app = createServer(new RemoteLoginStore(serverSettings) as unknown as TaskStore, { daemon: { token: daemonToken }, ...createInertOptions() }); + const response = await request(app, "GET", "/remote-login?rt=new-token"); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`/?token=${daemonToken}`); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-remote-access.test.ts b/packages/dashboard/src/__tests__/routes-remote-access.test.ts index be788599dd..9710ab23f4 100644 --- a/packages/dashboard/src/__tests__/routes-remote-access.test.ts +++ b/packages/dashboard/src/__tests__/routes-remote-access.test.ts @@ -135,8 +135,12 @@ describe("remote access API route contracts", () => { providers: expect.objectContaining({ cloudflare: expect.objectContaining({ quickTunnel: true }), }), + tokenStrategy: expect.objectContaining({ + persistent: expect.objectContaining({ token: "frt_persistent_token" }), + }), }), })); + expect(JSON.stringify(putRes.body)).not.toContain("frt_persistent_token"); }); it("persists Tailscale accept-routes and lifecycle fields without erasing populated branches", async () => { diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index f0c4e80347..4adb911fe6 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -500,6 +500,12 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin }, }, }); + /* + FNXC:RemoteAccessAuth 2026-08-18-06:49: + A token minted from any remote surface must authenticate at /remote-login + immediately, including when its server-level store was cached before mint. + */ + invalidateAllGlobalSettingsCaches(); return token; } @@ -877,6 +883,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin }; await scopedStore.updateGlobalSettings({ remoteAccess: nextRemoteAccess }); + invalidateAllGlobalSettingsCaches(); res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) }); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -955,6 +962,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin activeProvider: provider, }, }); + invalidateAllGlobalSettingsCaches(); res.json({ activeProvider: provider }); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -1098,6 +1106,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin }, }, }); + invalidateAllGlobalSettingsCaches(); res.json({ token, maskedToken: maskRemoteToken(token) }); } catch (err: unknown) { if (err instanceof ApiError) throw err; diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 72d4164a92..727fdd2213 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -2201,9 +2201,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT app.get("/remote-login", async (req, res) => { const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined; - let settings: Awaited>; + let settings: Awaited["getSettings"]>>; try { - settings = await store.getSettings(); + /* + FNXC:RemoteAccessAuth 2026-08-18-06:49: + Remote links are public handoffs, but their tokens must be resolved from + canonical global settings rather than a project-merged snapshot. A token + minted by any remote surface must work while daemon authentication is on. + */ + settings = await store.getGlobalSettingsStore().getSettings(); } catch { res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" }); return;