FN-004: fix remote access token handoff

Ensure freshly generated Remote Access links authenticate against current global settings.

- Synchronize global-settings caches across token writers and readers
- Resolve remote-login tokens from canonical global settings
- Cover token handoff behavior and document persistent-token semantics

Files changed:
 .changeset/fn-004-remote-token-auth.md             |   7 +
 docs/remote-access.md                              |   6 +-
 .../core/src/__tests__/global-settings.test.ts     |  41 +++-
 .../postgres/settings-persistence.pg.test.ts       |  37 ++++
 packages/core/src/config/global-settings.ts        |  46 ++++-
 .../app/__tests__/auth-token-capture.test.ts       |  32 ++++
 .../app/__tests__/settings-save-split.test.ts      |   4 +
 .../src/__tests__/remote-login-handoff.test.ts     | 213 +++++++++++++++++++++
 .../src/__tests__/routes-remote-access.test.ts     |   4 +
 .../src/routes/register-settings-memory-routes.ts  |   9 +
 packages/dashboard/src/server.ts                   |  10 +-
 11 files changed, 400 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-004

Fusion-Task-Lineage: 1a64c345-a590-474e-afbf-af5e5d6df979

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 07:34:07 +00:00
parent d45c80d7f3
commit 200b31089e
11 changed files with 400 additions and 9 deletions

View File

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

View File

@@ -287,8 +287,10 @@ Treat these links as secrets.
Fusion supports two token modes for remote login handoff: Fusion supports two token modes for remote login handoff:
1. **Persistent token** (`remoteAccess.tokenStrategy.persistent`) 1. **Persistent token** (`remoteAccess.tokenStrategy.persistent`)
- Stored in project settings. - Stored in global settings.
- Reused across generated links until regenerated. - 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. - `GET /api/remote/settings` returns a **masked** representation only.
2. **Short-lived token** (`remoteAccess.tokenStrategy.shortLived`) 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 | | 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=<token>` returns `401` `remote_token_missing` | Missing `rt` query token | Validate URL structure includes `?rt=<token>` | Regenerate/fetch URL via `/api/remote-access/auth/login-url`, `/api/remote/url`, or `/api/remote/qr` | | `GET /remote-login?rt=<token>` returns `401` `remote_token_missing` | Missing `rt` query token | Validate URL structure includes `?rt=<token>` | Regenerate/fetch URL via `/api/remote-access/auth/login-url`, `/api/remote/url`, or `/api/remote/qr` |
| `GET /remote-login?rt=<token>` 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=<token>` 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=<token>` 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=<token>` 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 | | 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 | | 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 |

View File

@@ -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 { GlobalSettingsStore, defaultGlobalDir } from "../config/global-settings.js";
import { DEFAULT_GLOBAL_SETTINGS } from "../types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "../types.js";
import { readFile, rm, writeFile, mkdir } from "node:fs/promises"; import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
@@ -959,5 +959,44 @@ describe("GlobalSettingsStore", () => {
expect(raw.defaultProvider).toBe("anthropic"); expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBeUndefined(); 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<void>((resolve) => {
const originalReadRaw = sibling.readRaw.bind(sibling);
vi.spyOn(sibling, "readRaw").mockImplementationOnce(async () => {
resolve();
await new Promise<void>((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<string, unknown>).futureSetting).toBeUndefined();
});
}); });
}); });

View File

@@ -13,6 +13,7 @@ import {
} from "../../__test-utils__/pg-test-harness.js"; } from "../../__test-utils__/pg-test-harness.js";
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js"; import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { DEFAULT_GLOBAL_SETTINGS } from "../../types.js";
const credentialLaneKeys = [ const credentialLaneKeys = [
["defaultProvider", "defaultCredentialInstanceId"], ["defaultProvider", "defaultCredentialInstanceId"],
@@ -45,6 +46,42 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => {
expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); 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 () => { it("persists project-level settings via updateSettings", async () => {
const store = h.store(); const store = h.store();
await store.updateSettings({ await store.updateSettings({

View File

@@ -34,6 +34,19 @@ remain layerless; production always initializes the central PostgreSQL layer.
*/ */
const directGlobalRevisionLayers = new Map<string, Promise<AsyncDataLayer>>(); const directGlobalRevisionLayers = new Map<string, Promise<AsyncDataLayer>>();
/** Process-local settings-file generations keep independently constructed stores coherent. */
const settingsCacheEpochs = new Map<string, number>();
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 { function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir(); 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. */ /** Write-through cache for settings. Invalidated on every updateSettings() call. */
private cachedSettings: GlobalSettings | null = null; private cachedSettings: GlobalSettings | null = null;
private cachedSettingsEpoch = 0;
/** Promise chain for serializing read-modify-write cycles */ /** Promise chain for serializing read-modify-write cycles */
private lock: Promise<void> = Promise.resolve(); private lock: Promise<void> = Promise.resolve();
@@ -199,6 +213,7 @@ export class GlobalSettingsStore {
await mkdir(this.dir, { recursive: true }); await mkdir(this.dir, { recursive: true });
if (!existsSync(this.settingsPath)) { if (!existsSync(this.settingsPath)) {
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS); await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
bumpSettingsCacheEpoch(this.settingsPath);
return true; return true;
} }
return false; return false;
@@ -248,12 +263,27 @@ export class GlobalSettingsStore {
* If the file doesn't exist or is invalid, returns defaults without throwing. * If the file doesn't exist or is invalid, returns defaults without throwing.
*/ */
async getSettings(): Promise<GlobalSettings> { async getSettings(): Promise<GlobalSettings> {
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; 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 mkdir(this.dir, { recursive: true });
await this.atomicWrite(withDefaults); 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.cachedSettings = withDefaults;
this.cachedSettingsEpoch = bumpSettingsCacheEpoch(this.settingsPath);
return this.cachedSettings; return this.cachedSettings;
}); });
} }
@@ -392,6 +429,7 @@ export class GlobalSettingsStore {
*/ */
invalidateCache(): void { invalidateCache(): void {
this.cachedSettings = null; this.cachedSettings = null;
this.cachedSettingsEpoch = bumpSettingsCacheEpoch(this.settingsPath);
} }
// ── Private helpers ───────────────────────────────────────────── // ── Private helpers ─────────────────────────────────────────────

View File

@@ -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");
});
});

View File

@@ -524,6 +524,10 @@ describe("splitSettingsSave", () => {
}); });
expect(projectPatch).toEqual({}); expect(projectPatch).toEqual({});
expect(globalPatch.remoteAccess?.tokenStrategy).toEqual({
shortLived: expect.any(Object),
});
expect(JSON.stringify(globalPatch.remoteAccess)).not.toContain("persistent");
expect(globalPatch).toEqual({ expect(globalPatch).toEqual({
remoteAccess: expect.objectContaining({ remoteAccess: expect.objectContaining({
activeProvider: "tailscale", activeProvider: "tailscale",

View File

@@ -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<Settings> => this.globalSettings.getSettings() as Promise<Settings>);
getSettingsFast = this.getSettings;
updateGlobalSettings = vi.fn(async (patch: Record<string, unknown>) => 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<typeof createServer>, 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}`);
});
});

View File

@@ -135,8 +135,12 @@ describe("remote access API route contracts", () => {
providers: expect.objectContaining({ providers: expect.objectContaining({
cloudflare: expect.objectContaining({ quickTunnel: true }), 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 () => { it("persists Tailscale accept-routes and lifecycle fields without erasing populated branches", async () => {

View File

@@ -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; return token;
} }
@@ -877,6 +883,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}; };
await scopedStore.updateGlobalSettings({ remoteAccess: nextRemoteAccess }); await scopedStore.updateGlobalSettings({ remoteAccess: nextRemoteAccess });
invalidateAllGlobalSettingsCaches();
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) }); res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -955,6 +962,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
activeProvider: provider, activeProvider: provider,
}, },
}); });
invalidateAllGlobalSettingsCaches();
res.json({ activeProvider: provider }); res.json({ activeProvider: provider });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -1098,6 +1106,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}, },
}, },
}); });
invalidateAllGlobalSettingsCaches();
res.json({ token, maskedToken: maskRemoteToken(token) }); res.json({ token, maskedToken: maskRemoteToken(token) });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;

View File

@@ -2201,9 +2201,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
app.get("/remote-login", async (req, res) => { app.get("/remote-login", async (req, res) => {
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined; const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
let settings: Awaited<ReturnType<typeof store.getSettings>>; let settings: Awaited<ReturnType<ReturnType<typeof store.getGlobalSettingsStore>["getSettings"]>>;
try { 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 { } catch {
res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" }); res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" });
return; return;