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:
7
.changeset/fn-004-remote-token-auth.md
Normal file
7
.changeset/fn-004-remote-token-auth.md
Normal 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.
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────
|
||||||
|
|||||||
32
packages/dashboard/app/__tests__/auth-token-capture.test.ts
Normal file
32
packages/dashboard/app/__tests__/auth-token-capture.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
213
packages/dashboard/src/__tests__/remote-login-handoff.test.ts
Normal file
213
packages/dashboard/src/__tests__/remote-login-handoff.test.ts
Normal 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user