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:
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user