feat(FN-2520): add remoteAccess auth link token mode support

- Extend project settings schema/types with remoteAccess defaults and auth link token mode fields
- Update settings store patch handling to deep-merge remoteAccess updates without clobbering sibling keys
- Add dashboard/API wiring for remoteAccess controls, including legacy settings route handling
- Expand core and dashboard tests for remoteAccess settings behavior, merge semantics, and UI coverage
- Align settings reference docs with the implemented remoteAccess schema and options
This commit is contained in:
Fusion
2026-04-26 03:55:57 -07:00
committed by gsxdsm
parent b0c6b14022
commit 8a261ae052
10 changed files with 560 additions and 32 deletions

View File

@@ -50,7 +50,9 @@ describe("settings key parity", () => {
expect(isGlobalSettingsKey("maxConcurrent")).toBe(false);
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
expect(isProjectSettingsKey("remoteAccess")).toBe(true);
expect(isProjectSettingsKey("themeMode")).toBe(false);
expect(isGlobalSettingsKey("remoteAccess")).toBe(false);
});
it("includes heartbeatMultiplier in project defaults", () => {

View File

@@ -2620,6 +2620,132 @@ describe("TaskStore", () => {
});
});
describe("remoteAccess settings", () => {
const baseRemoteAccess = {
enabled: true,
activeProvider: "cloudflare" as const,
providers: {
tailscale: {
enabled: true,
hostname: "tailscale.example.ts.net",
targetPort: 5173,
acceptRoutes: true,
},
cloudflare: {
enabled: true,
tunnelName: "main-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://project.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: "persist-token",
},
shortLived: {
enabled: false,
ttlMs: 900_000,
maxTtlMs: 86_400_000,
},
},
lifecycle: {
rememberLastRunning: true,
wasRunningOnShutdown: true,
lastRunningProvider: "cloudflare" as const,
},
};
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
await store.updateSettings({
remoteAccess: {
providers: {
tailscale: {
enabled: false,
hostname: "alt-tail.ts.net",
targetPort: 3000,
acceptRoutes: false,
},
},
},
} as any);
const settings = await store.getSettings();
expect(settings.remoteAccess?.providers.cloudflare).toEqual(baseRemoteAccess.providers.cloudflare);
});
it("patching remoteAccess.tokenStrategy.shortLived preserves tokenStrategy.persistent", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
await store.updateSettings({
remoteAccess: {
tokenStrategy: {
shortLived: {
enabled: true,
ttlMs: 120_000,
maxTtlMs: 300_000,
},
},
},
} as any);
const settings = await store.getSettings();
expect(settings.remoteAccess?.tokenStrategy.persistent).toEqual(baseRemoteAccess.tokenStrategy.persistent);
});
it("patching only activeProvider preserves providers, tokenStrategy, and lifecycle", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
await store.updateSettings({
remoteAccess: {
activeProvider: "tailscale",
},
} as any);
const settings = await store.getSettings();
expect(settings.remoteAccess?.activeProvider).toBe("tailscale");
expect(settings.remoteAccess?.providers).toEqual(baseRemoteAccess.providers);
expect(settings.remoteAccess?.tokenStrategy).toEqual(baseRemoteAccess.tokenStrategy);
expect(settings.remoteAccess?.lifecycle).toEqual(baseRemoteAccess.lifecycle);
});
it("nested null clear only removes the targeted token field", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
await store.updateSettings({
remoteAccess: {
tokenStrategy: {
persistent: {
token: null,
},
},
},
} as any);
const settings = await store.getSettings();
expect(settings.remoteAccess?.tokenStrategy.persistent.enabled).toBe(true);
expect(settings.remoteAccess?.tokenStrategy.persistent.token).toBeUndefined();
expect(settings.remoteAccess?.tokenStrategy.shortLived).toEqual(baseRemoteAccess.tokenStrategy.shortLived);
});
it("top-level null clear removes remoteAccess override and falls back to defaults", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
await store.updateSettings({ remoteAccess: null as any });
const settings = await store.getSettings();
expect(settings.remoteAccess?.enabled).toBe(false);
expect(settings.remoteAccess?.activeProvider).toBeNull();
expect(settings.remoteAccess?.tokenStrategy.persistent.token).toBeNull();
const db = (store as any).db;
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
const projectSettings = row?.settings ? JSON.parse(row.settings) : {};
expect(projectSettings.remoteAccess).toBeUndefined();
});
});
// ── Experimental Features Tests ─────────────────────────────────
describe("experimentalFeatures settings", () => {

View File

@@ -156,6 +156,40 @@ export const DEFAULT_PROJECT_SETTINGS = {
missionHealthCheckIntervalMs: 300_000,
agentPrompts: undefined,
promptOverrides: undefined,
remoteAccess: {
enabled: false,
activeProvider: null,
providers: {
tailscale: {
enabled: false,
hostname: "",
targetPort: 0,
acceptRoutes: false,
},
cloudflare: {
enabled: false,
tunnelName: "",
tunnelToken: null,
ingressUrl: "",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: null,
},
shortLived: {
enabled: false,
ttlMs: 900000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: false,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
},
reflectionEnabled: false,
reflectionIntervalMs: 3_600_000,
reflectionAfterTask: true,

View File

@@ -234,6 +234,38 @@ function canonicalizeSettings(settings: Settings): Settings {
return base;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function deepMergeWithNullDelete(
existingValue: unknown,
patchValue: Record<string, unknown>,
): Record<string, unknown> | undefined {
const merged: Record<string, unknown> = isPlainObject(existingValue) ? { ...existingValue } : {};
for (const [key, value] of Object.entries(patchValue)) {
if (value === null) {
delete merged[key];
continue;
}
if (isPlainObject(value)) {
const nested = deepMergeWithNullDelete(merged[key], value);
if (nested === undefined) {
delete merged[key];
} else {
merged[key] = nested;
}
continue;
}
merged[key] = value;
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
@@ -1451,6 +1483,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
// Handle deep merge + targeted null clear semantics for remoteAccess
const incomingRemoteAccess = (projectPatch as Record<string, unknown>)["remoteAccess"];
if (incomingRemoteAccess === null) {
delete (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
delete (projectPatch as Record<string, unknown>)["remoteAccess"];
} else if (isPlainObject(incomingRemoteAccess)) {
const existingRemoteAccess = (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
const mergedRemoteAccess = deepMergeWithNullDelete(existingRemoteAccess, incomingRemoteAccess);
if (mergedRemoteAccess === undefined) {
delete (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
delete (projectPatch as Record<string, unknown>)["remoteAccess"];
} else {
(config.settings as unknown as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
(projectPatch as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
}
}
// Handle null values for other top-level keys (non-promptOverrides)
for (const key of Object.keys(projectPatch)) {
if ((projectPatch as Record<string, unknown>)[key] === null) {

View File

@@ -1178,6 +1178,49 @@ export interface GlobalSettings {
vitestKillThresholdPct?: number;
}
export type RemoteAccessProvider = "tailscale" | "cloudflare";
export interface RemoteAccessProvidersConfig {
tailscale: {
enabled: boolean;
hostname: string;
targetPort: number;
acceptRoutes: boolean;
};
cloudflare: {
enabled: boolean;
tunnelName: string;
tunnelToken: string | null;
ingressUrl: string;
};
}
export interface RemoteAccessTokenStrategyConfig {
persistent: {
enabled: boolean;
token: string | null;
};
shortLived: {
enabled: boolean;
ttlMs: number;
maxTtlMs: number;
};
}
export interface RemoteAccessLifecycleConfig {
rememberLastRunning: boolean;
wasRunningOnShutdown: boolean;
lastRunningProvider: RemoteAccessProvider | null;
}
export interface RemoteAccessProjectSettings {
enabled: boolean;
activeProvider: RemoteAccessProvider | null;
providers: RemoteAccessProvidersConfig;
tokenStrategy: RemoteAccessTokenStrategyConfig;
lifecycle: RemoteAccessLifecycleConfig;
}
/**
* Project-level settings stored in `.fusion/config.json`.
*
@@ -1569,6 +1612,10 @@ export interface ProjectSettings {
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
* "merger-conflicts". */
promptOverrides?: Record<string, string | null>;
/** Project-scoped remote access configuration persisted in `.fusion/config.json`.
* Stores both provider configs, active provider selection, token strategy,
* and lifecycle restart metadata for remote tunnel orchestration. */
remoteAccess?: RemoteAccessProjectSettings;
/** Enable/disable agent self-reflection workflows. Default: false. */
reflectionEnabled?: boolean;
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */