FN-7345: Persist remote access settings saves

Remote Access settings now save from the main Settings dialog without requiring tunnel startup.

- Map flattened Remote Access form fields into the canonical global remoteAccess payload during section saves.
- Preserve provider, token, and lifecycle branches while saving Tailscale accept-routes and remember-running options.
- Cover the UI save flow, split helper, API route contract, docs, and release note.

Files changed:
 .changeset/fn-7345-remote-settings-windows.md      |   7 +
 docs/dashboard-guide.md                            |   1 +
 .../app/__tests__/settings-save-split.test.ts      |  56 ++++++++
 .../SettingsModal.remote-notifications.test.tsx    | 144 +++++++++++++++++++++
 .../app/components/settings/save-split.ts          | 103 +++++++++++++++
 .../src/__tests__/routes-remote-access.test.ts     |  54 ++++++++
 6 files changed, 365 insertions(+)

Fusion-Task-Id: FN-7345

Fusion-Task-Lineage: 9d1eb7f7-7e13-4070-af7d-eb6c6a41fe44

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 23:12:40 -07:00
parent d786e7b9c9
commit 519f158400
6 changed files with 365 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Persist Remote Access settings when saving from the dashboard on Windows.
category: fix
dev: Main Settings Save now writes the canonical remoteAccess settings payload.

View File

@@ -1237,6 +1237,7 @@ Dashboard remote controls live in **Settings → Remote Access**.
From this section, operators can:
- Configure Tailscale and Cloudflare provider fields
- Save provider options such as Tailscale **Accept routes** and **Remember last running state** with the main Settings **Save** button; starting a tunnel is not required for these settings to persist.
- Activate the current provider
- Start/stop tunnel lifecycle manually
- Generate login URLs / QR payloads using persistent or short-lived token mode

View File

@@ -239,6 +239,62 @@ describe("splitSettingsSave", () => {
expect(projectResult.projectPatch).toEqual({ mcpServers: projectMcp });
});
it("maps flattened remote access fields to the canonical global remoteAccess patch", () => {
const { globalPatch, projectPatch } = splitSettingsSave({
payload: {
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: false,
remoteTailscaleHostname: "tail.example.ts.net",
remoteTailscaleTargetPort: 4040,
remoteTailscaleAcceptRoutes: true,
remoteCloudflareEnabled: true,
remoteCloudflareQuickTunnel: false,
remoteCloudflareTunnelName: "demo-tunnel",
remoteCloudflareTunnelToken: "cf-secret-token",
remoteCloudflareIngressUrl: "https://remote.example.com",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 120000,
remoteShortLivedMaxTtlMs: 86400000,
remoteRememberLastRunning: true,
remoteWasRunningOnShutdown: true,
remoteLastStartedProvider: "cloudflare",
},
initialValues: null,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "remote",
});
expect(projectPatch).toEqual({});
expect(globalPatch).toEqual({
remoteAccess: expect.objectContaining({
activeProvider: "tailscale",
providers: expect.objectContaining({
tailscale: expect.objectContaining({
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: true,
}),
cloudflare: expect.objectContaining({
enabled: true,
quickTunnel: false,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
}),
}),
tokenStrategy: expect.objectContaining({
shortLived: expect.objectContaining({ enabled: true, ttlMs: 120000, maxTtlMs: 86400000 }),
}),
lifecycle: expect.objectContaining({
rememberLastRunning: true,
wasRunningOnShutdown: true,
lastRunningProvider: "cloudflare",
}),
}),
});
});
it("routes enabled built-in workflow ids as a changed project setting", () => {
const { projectPatch } = splitSettingsSave({
payload: { enabledBuiltinWorkflowIds: ["builtin:coding"] },

View File

@@ -339,6 +339,150 @@ describe("SettingsModal", () => {
});
expect(mockStartRemoteTunnel).toHaveBeenCalled();
});
it("main Settings Save persists Tailscale and lifecycle remote fields without starting a tunnel", async () => {
await settingsModalUser.click(screen.getByLabelText("Tailscale"));
await settingsModalUser.click(screen.getByLabelText("Accept routes"));
await openAdvancedSettings();
await settingsModalUser.click(screen.getByLabelText("Remember last running state"));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteAccess: expect.objectContaining({
activeProvider: "tailscale",
providers: expect.objectContaining({
tailscale: expect.objectContaining({
enabled: true,
acceptRoutes: true,
}),
}),
lifecycle: expect.objectContaining({
rememberLastRunning: true,
}),
}),
}),
);
});
expect(mockUpdateRemoteSettings).not.toHaveBeenCalled();
expect(mockStartRemoteTunnel).not.toHaveBeenCalled();
});
});
it("reopens with Remote Access checkboxes checked after main Settings Save", async () => {
const firstRender = await renderModalSection("remote", "Remote Access");
await settingsModalUser.click(screen.getByLabelText("Tailscale"));
await settingsModalUser.click(screen.getByLabelText("Accept routes"));
await openAdvancedSettings();
await settingsModalUser.click(screen.getByLabelText("Remember last running state"));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteAccess: expect.objectContaining({
providers: expect.objectContaining({
tailscale: expect.objectContaining({ acceptRoutes: true }),
}),
lifecycle: expect.objectContaining({ rememberLastRunning: true }),
}),
}),
);
});
expect(mockUpdateRemoteSettings).not.toHaveBeenCalled();
expect(mockStartRemoteTunnel).not.toHaveBeenCalled();
firstRender.unmount();
mockFetchRemoteSettings.mockResolvedValueOnce({
settings: {
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
remoteTailscaleHostname: "",
remoteTailscaleTargetPort: 4040,
remoteTailscaleAcceptRoutes: true,
remoteCloudflareEnabled: false,
remoteCloudflareQuickTunnel: true,
remoteCloudflareTunnelName: "",
remoteCloudflareTunnelToken: null,
remoteCloudflareIngressUrl: "",
remotePersistentToken: null,
remoteShortLivedEnabled: false,
remoteShortLivedTtlMs: 900000,
remoteShortLivedMaxTtlMs: 86400000,
remoteRememberLastRunning: true,
remoteWasRunningOnShutdown: false,
remoteLastStartedProvider: null,
},
});
await renderModalSection("remote", "Remote Access");
expect(await screen.findByLabelText("Accept routes")).toBeChecked();
await openAdvancedSettings();
expect(screen.getByLabelText("Remember last running state")).toBeChecked();
});
it("preserves populated remote provider and token branches on main Settings Save", async () => {
mockFetchRemoteSettings.mockResolvedValueOnce({
settings: {
remoteActiveProvider: "cloudflare",
remoteTailscaleEnabled: true,
remoteTailscaleHostname: "tail.example.ts.net",
remoteTailscaleTargetPort: 4040,
remoteTailscaleAcceptRoutes: false,
remoteCloudflareEnabled: true,
remoteCloudflareQuickTunnel: false,
remoteCloudflareTunnelName: "demo-tunnel",
remoteCloudflareTunnelToken: "cf-secret-token",
remoteCloudflareIngressUrl: "https://remote.example.com",
remotePersistentToken: "frt_••••",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 120000,
remoteShortLivedMaxTtlMs: 86400000,
remoteRememberLastRunning: false,
remoteWasRunningOnShutdown: true,
remoteLastStartedProvider: "cloudflare",
},
});
await renderModalSection("remote", "Remote Access");
await settingsModalUser.click(screen.getByLabelText("Tailscale"));
await settingsModalUser.click(screen.getByLabelText("Accept routes"));
await openAdvancedSettings();
await settingsModalUser.click(screen.getByLabelText("Remember last running state"));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteAccess: expect.objectContaining({
activeProvider: "tailscale",
providers: expect.objectContaining({
tailscale: expect.objectContaining({
enabled: true,
hostname: "tail.example.ts.net",
acceptRoutes: true,
}),
cloudflare: expect.objectContaining({
enabled: true,
quickTunnel: false,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
}),
}),
tokenStrategy: expect.objectContaining({
shortLived: expect.objectContaining({ enabled: true, ttlMs: 120000 }),
}),
lifecycle: expect.objectContaining({
rememberLastRunning: true,
wasRunningOnShutdown: true,
lastRunningProvider: "cloudflare",
}),
}),
}),
);
});
});
it("renders remote-status-bar with stopped state and omits share block when not running", async () => {

View File

@@ -45,6 +45,9 @@ export const MODEL_LANE_KEYS = [
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
type RemoteAccessProvider = "tailscale" | "cloudflare";
type RemoteAccessPatch = NonNullable<GlobalSettings["remoteAccess"]>;
const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
appearance: new Set([
"themeMode",
@@ -201,6 +204,94 @@ function settingsValueEquals(left: unknown, right: unknown): boolean {
return false;
}
function readString(payload: Record<string, unknown>, key: string): string | undefined {
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
return String(payload[key] ?? "");
}
function readNullableString(payload: Record<string, unknown>, key: string): string | null | undefined {
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
return payload[key] ? String(payload[key]) : null;
}
function readBoolean(payload: Record<string, unknown>, key: string): boolean | undefined {
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
return Boolean(payload[key]);
}
function readNumber(payload: Record<string, unknown>, key: string, fallback: number): number | undefined {
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
return Number(payload[key] ?? fallback);
}
function assignIfPresent<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void {
if (value !== undefined) {
target[key] = value;
}
}
function buildRemoteAccessPatch(payload: Record<string, unknown>): Partial<RemoteAccessPatch> | null {
const patch: Partial<RemoteAccessPatch> = {};
const activeProvider = hasOwn(payload, "remoteActiveProvider")
? (payload.remoteActiveProvider as RemoteAccessProvider | null)
: undefined;
if (activeProvider !== undefined) {
patch.activeProvider = activeProvider;
}
const tailscalePatch: Partial<RemoteAccessPatch["providers"]["tailscale"]> = {};
assignIfPresent(tailscalePatch, "enabled", readBoolean(payload, "remoteTailscaleEnabled"));
assignIfPresent(tailscalePatch, "hostname", readString(payload, "remoteTailscaleHostname"));
assignIfPresent(tailscalePatch, "targetPort", readNumber(payload, "remoteTailscaleTargetPort", 4040));
assignIfPresent(tailscalePatch, "acceptRoutes", readBoolean(payload, "remoteTailscaleAcceptRoutes"));
if (activeProvider === "tailscale") {
tailscalePatch.enabled = true;
}
const cloudflarePatch: Partial<RemoteAccessPatch["providers"]["cloudflare"]> = {};
assignIfPresent(cloudflarePatch, "enabled", readBoolean(payload, "remoteCloudflareEnabled"));
assignIfPresent(cloudflarePatch, "quickTunnel", readBoolean(payload, "remoteCloudflareQuickTunnel"));
assignIfPresent(cloudflarePatch, "tunnelName", readString(payload, "remoteCloudflareTunnelName"));
assignIfPresent(cloudflarePatch, "tunnelToken", readNullableString(payload, "remoteCloudflareTunnelToken"));
assignIfPresent(cloudflarePatch, "ingressUrl", readString(payload, "remoteCloudflareIngressUrl"));
if (activeProvider === "cloudflare") {
cloudflarePatch.enabled = true;
}
if (Object.keys(tailscalePatch).length > 0 || Object.keys(cloudflarePatch).length > 0) {
patch.providers = {} as RemoteAccessPatch["providers"];
if (Object.keys(tailscalePatch).length > 0) {
patch.providers.tailscale = tailscalePatch as RemoteAccessPatch["providers"]["tailscale"];
}
if (Object.keys(cloudflarePatch).length > 0) {
patch.providers.cloudflare = cloudflarePatch as RemoteAccessPatch["providers"]["cloudflare"];
}
}
const shortLivedPatch: Partial<RemoteAccessPatch["tokenStrategy"]["shortLived"]> = {};
assignIfPresent(shortLivedPatch, "enabled", readBoolean(payload, "remoteShortLivedEnabled"));
assignIfPresent(shortLivedPatch, "ttlMs", readNumber(payload, "remoteShortLivedTtlMs", 900_000));
assignIfPresent(shortLivedPatch, "maxTtlMs", readNumber(payload, "remoteShortLivedMaxTtlMs", 86_400_000));
if (Object.keys(shortLivedPatch).length > 0) {
patch.tokenStrategy = {
shortLived: shortLivedPatch as RemoteAccessPatch["tokenStrategy"]["shortLived"],
} as RemoteAccessPatch["tokenStrategy"];
}
const lifecyclePatch: Partial<RemoteAccessPatch["lifecycle"]> = {};
assignIfPresent(lifecyclePatch, "rememberLastRunning", readBoolean(payload, "remoteRememberLastRunning"));
assignIfPresent(lifecyclePatch, "wasRunningOnShutdown", readBoolean(payload, "remoteWasRunningOnShutdown"));
if (hasOwn(payload, "remoteLastStartedProvider") && payload.remoteLastStartedProvider !== undefined) {
lifecyclePatch.lastRunningProvider = payload.remoteLastStartedProvider as RemoteAccessProvider | null;
}
if (Object.keys(lifecyclePatch).length > 0) {
patch.lifecycle = lifecyclePatch as RemoteAccessPatch["lifecycle"];
}
return Object.keys(patch).length > 0 ? patch : null;
}
/**
* Split a normalized settings form payload into global and project patches,
* preserving null-as-delete and changed-only-project-write semantics.
@@ -212,6 +303,18 @@ export function splitSettingsSave({
activeSection,
}: SaveSplitInput): SaveSplitResult {
const globalPatch: Partial<GlobalSettings> = {};
if (activeSection === "remote") {
/*
FNXC:RemoteAccessSettings 2026-06-30-00:00:
Main Settings Save must persist the Remote Access section's flattened form fields into the canonical nested remoteAccess object. Windows users commonly configure Tailscale options and click Save without starting the tunnel, so this path cannot rely on the Start Tunnel auto-save.
*/
const remoteAccessPatch = buildRemoteAccessPatch(payload);
if (remoteAccessPatch) {
globalPatch.remoteAccess = remoteAccessPatch as RemoteAccessPatch;
}
}
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") {
continue;

View File

@@ -132,6 +132,60 @@ describe("remote access API route contracts", () => {
}));
});
it("persists Tailscale accept-routes and lifecycle fields without erasing populated branches", async () => {
let remoteAccess = buildRemoteAccessSettings();
const store = createMockStore({
getSettings: vi.fn(async () => ({ remoteAccess })),
updateGlobalSettings: vi.fn(async (patch: { remoteAccess?: typeof remoteAccess }) => {
if (patch.remoteAccess) {
remoteAccess = patch.remoteAccess;
}
return { remoteAccess };
}),
});
const { app } = createApp({ store });
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
remoteTailscaleAcceptRoutes: true,
remoteRememberLastRunning: true,
});
expect(putRes.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({
remoteAccess: expect.objectContaining({
activeProvider: "tailscale",
providers: expect.objectContaining({
tailscale: expect.objectContaining({ acceptRoutes: true }),
cloudflare: expect.objectContaining({
quickTunnel: false,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
}),
}),
tokenStrategy: expect.objectContaining({
persistent: expect.objectContaining({ token: "frt_persistent_token" }),
shortLived: expect.objectContaining({ enabled: true, ttlMs: 120000 }),
}),
lifecycle: expect.objectContaining({ rememberLastRunning: true }),
}),
});
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
expect(getRes.status).toBe(200);
expect(getRes.body.settings).toMatchObject({
remoteActiveProvider: "tailscale",
remoteTailscaleAcceptRoutes: true,
remoteRememberLastRunning: true,
remoteCloudflareQuickTunnel: false,
remoteCloudflareTunnelName: "demo-tunnel",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 120000,
});
});
it("returns default remote settings payload when remoteAccess is missing", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({}),