feat(FN-2517): add remote access controls across dashboard and TUI

- Add project-scoped remote settings and control APIs for provider activation, tunnel lifecycle, token generation, URL, and QR retrieval
- Extend Settings modal with a dedicated Remote Access section, provider forms, status/actions, and coverage in SettingsModal tests
- Wire dashboard TUI settings state to remote configuration/status and add interactive remote shortcuts for start/stop/token/url/qr actions
- Document remote-access behavior in architecture, CLI, and settings references and include a patch changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-26 00:20:43 -07:00
committed by gsxdsm
parent 7bc8cf945f
commit 89c599abf6
12 changed files with 865 additions and 15 deletions

View File

@@ -47,6 +47,10 @@ function makeInteractiveData(opts: {
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
remoteEnabled: false,
remoteActiveProvider: null,
remoteShortLivedEnabled: false,
remoteShortLivedTtlMs: 900000,
};
const models = opts.models ?? [];
return {
@@ -65,6 +69,16 @@ function makeInteractiveData(opts: {
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
listModels: () => models,
remote: {
getStatus: async () => ({ provider: null, state: "stopped", url: null, lastError: null }),
activateProvider: async () => {},
start: async () => {},
stop: async () => {},
regeneratePersistentToken: async () => {},
generateShortLivedToken: async () => ({ token: "short", expiresAt: new Date().toISOString(), ttlMs: 60000 }),
fetchUrl: async () => ({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null }),
fetchQr: async () => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg", data: "<svg/>" }),
},
git: {
getStatus: async () => ({
branch: "main",
@@ -317,6 +331,11 @@ describe("Settings view", () => {
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 600000,
remoteStatus: { provider: "tailscale", state: "running", url: "https://remote.example.com", lastError: null },
};
controller.setInteractiveData(makeInteractiveData({ settings }));
controller.setMode("interactive");

View File

@@ -448,6 +448,9 @@ function SettingsPanel({ state, isFocused }: { state: DashboardState; isFocused:
["pollMs", `${s.pollIntervalMs}`],
["paused", s.enginePaused ? "yes" : "no"],
["globalPause", s.globalPause ? "yes" : "no"],
["remoteEnabled", s.remoteEnabled ? "enabled" : "disabled"],
["remoteProvider", s.remoteActiveProvider ?? "none"],
["remoteState", s.remoteStatus?.state ?? "unknown"],
] as Array<[string, string]>
).map(([key, value]) => {
const isEnabled = value === "enabled" || value === "yes";
@@ -2102,7 +2105,7 @@ function AgentsView({ state }: { state: DashboardState }) {
// ── Settings interactive view ─────────────────────────────────────────────────
type SettingKey = "maxConcurrent" | "maxWorktrees" | "autoMerge" | "mergeStrategy" | "pollIntervalMs" | "enginePaused" | "globalPause";
type SettingKey = "maxConcurrent" | "maxWorktrees" | "autoMerge" | "mergeStrategy" | "pollIntervalMs" | "enginePaused" | "globalPause" | "remoteEnabled" | "remoteActiveProvider" | "remoteShortLivedEnabled" | "remoteShortLivedTtlMs";
interface SettingDef {
key: SettingKey;
@@ -2119,6 +2122,10 @@ const SETTING_DEFS: SettingDef[] = [
{ key: "pollIntervalMs", label: "Poll Interval (ms)", type: "number" },
{ key: "enginePaused", label: "Engine Paused", type: "boolean" },
{ key: "globalPause", label: "Global Pause", type: "boolean" },
{ key: "remoteEnabled", label: "Remote Access", type: "boolean" },
{ key: "remoteActiveProvider", label: "Remote Provider", type: "enum", options: ["tailscale", "cloudflare"] },
{ key: "remoteShortLivedEnabled", label: "Short-Lived Tokens", type: "boolean" },
{ key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" },
];
function SettingsInteractiveView({ state }: { state: DashboardState }) {
@@ -2128,12 +2135,25 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
const [saving, setSaving] = useState(false);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [detailFocused, setDetailFocused] = useState(false);
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
const [remoteTokenMeta, setRemoteTokenMeta] = useState<string | null>(null);
const data = state.interactiveData;
useEffect(() => {
if (!data) return;
data.getSettings().then(setLocalSettings).catch(() => {});
data.getSettings().then(async (settings) => {
if (data.remote) {
try {
const remoteStatus = await data.remote.getStatus();
setLocalSettings({ ...settings, remoteStatus });
} catch {
setLocalSettings(settings);
}
} else {
setLocalSettings(settings);
}
}).catch(() => {});
setModels(data.listModels());
}, [data]);
@@ -2145,7 +2165,8 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
try {
await data.updateSettings(partial);
const updated = await data.getSettings();
setLocalSettings(updated);
const remoteStatus = data.remote ? await data.remote.getStatus().catch(() => null) : null;
setLocalSettings(remoteStatus ? { ...updated, remoteStatus } : updated);
setStatusMsg("Saved");
} catch (err) {
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -2154,6 +2175,16 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
}
}
async function refreshRemoteStatus() {
if (!data || !localSettings) return;
try {
const remoteStatus = await data.remote.getStatus();
setLocalSettings({ ...localSettings, remoteStatus });
} catch {
// best-effort
}
}
useInput((input, key) => {
// Tab cycles list ↔ detail. Left/right also switch — list = left,
// detail = right, matching the visual layout (consistent with AgentsView).
@@ -2184,6 +2215,52 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
if (!selectedDef || !localSettings) return;
if (input === "R") {
void refreshRemoteStatus();
setStatusMsg("Remote status refreshed");
return;
}
if (data?.remote && input === "S") {
void data.remote.start().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "X") {
void data.remote.stop().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "P") {
void data.remote.regeneratePersistentToken().then(() => setStatusMsg("Persistent token regenerated"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "U") {
void data.remote.fetchUrl()
.then((result) => {
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
setStatusMsg("Remote URL fetched");
})
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "Q") {
void data.remote.fetchQr()
.then((result) => {
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : "persistent");
setStatusMsg("QR URL generated");
})
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (selectedDef.type === "boolean" && input === " ") {
const current = localSettings[selectedDef.key] as boolean;
const updated = { ...localSettings, [selectedDef.key]: !current };
@@ -2331,6 +2408,25 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
</Box>
)}
<Box height={1} />
<Text dimColor> Remote Access </Text>
<Box flexDirection="row" gap={1}>
<Text dimColor>Provider:</Text>
<Text>{localSettings.remoteActiveProvider ?? "none"}</Text>
<Text dimColor>State:</Text>
<Text color={localSettings.remoteStatus?.state === "running" ? "green" : "yellow"}>{localSettings.remoteStatus?.state ?? "unknown"}</Text>
</Box>
{localSettings.remoteStatus?.url && (
<Text dimColor wrap="truncate-end">URL: {localSettings.remoteStatus.url}</Text>
)}
{remoteUrl && (
<Text dimColor wrap="truncate-end">Auth URL: {remoteUrl}</Text>
)}
{remoteTokenMeta && (
<Text dimColor>{remoteTokenMeta}</Text>
)}
<Text dimColor>[S] start [X] stop [P] regenerate token [U] URL [Q] QR URL [R] refresh</Text>
{/* Models subsection */}
{models.length > 0 && (
<>
@@ -2357,7 +2453,7 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
</Box>
<Box paddingX={1}>
<Text dimColor>[Tab] switch panel select setting [Space] toggle bool [+/-] adjust num [/] cycle enum</Text>
<Text dimColor>[Tab] switch panel select setting [Space] toggle bool [+/-] adjust num [/] cycle enum [S/X/P/U/Q/R] remote actions</Text>
</Box>
</Box>
);

View File

@@ -51,6 +51,13 @@ export interface SystemStats {
platform: string;
}
export interface RemoteStatusValue {
provider: "tailscale" | "cloudflare" | null;
state: "stopped" | "starting" | "running" | "error";
url: string | null;
lastError: string | null;
}
export interface SettingsValues {
maxConcurrent: number;
maxWorktrees: number;
@@ -59,6 +66,11 @@ export interface SettingsValues {
pollIntervalMs: number;
enginePaused: boolean;
globalPause: boolean;
remoteEnabled: boolean;
remoteActiveProvider: "tailscale" | "cloudflare" | null;
remoteShortLivedEnabled: boolean;
remoteShortLivedTtlMs: number;
remoteStatus?: RemoteStatusValue;
}
export interface UtilityAction {
@@ -245,6 +257,16 @@ export interface InteractiveData {
getSettings: () => Promise<SettingsValues>;
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
listModels: () => ModelItem[];
remote: {
getStatus: () => Promise<RemoteStatusValue>;
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
regeneratePersistentToken: () => Promise<void>;
generateShortLivedToken: (ttlMs: number) => Promise<{ token: string; expiresAt: string; ttlMs: number }>;
fetchUrl: () => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
fetchQr: () => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
};
git: {
getStatus: (projectPath: string) => Promise<GitStatus>;
listCommits: (projectPath: string, limit?: number) => Promise<GitCommit[]>;

View File

@@ -722,6 +722,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pollIntervalMs: fullSettings.pollIntervalMs ?? 60_000,
enginePaused: fullSettings.enginePaused ?? false,
globalPause: fullSettings.globalPause ?? false,
remoteEnabled: Boolean(fullSettings.remoteEnabled),
remoteActiveProvider: (fullSettings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
remoteShortLivedEnabled: Boolean(fullSettings.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(fullSettings.remoteShortLivedTtlMs ?? 900_000),
};
}
return {
@@ -732,6 +736,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pollIntervalMs: 60_000,
enginePaused: paused,
globalPause: false,
remoteEnabled: false,
remoteActiveProvider: null,
remoteShortLivedEnabled: false,
remoteShortLivedTtlMs: 900_000,
};
},
onPersistVitestKillSettings: async (partial) => {
@@ -866,6 +874,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
enginePaused: settings.enginePaused ?? false,
globalPause: settings.globalPause ?? false,
remoteEnabled: Boolean(settings.remoteEnabled),
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
});
} catch {
// Ignore errors refreshing settings
@@ -1834,6 +1846,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
enginePaused: settings.enginePaused ?? false,
globalPause: settings.globalPause ?? false,
remoteEnabled: Boolean(settings.remoteEnabled),
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
});
// Hydrate the TUI memory guard from persisted global settings so the
@@ -1881,6 +1897,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// are cached so repeated panel switches don't re-init SQLite.
if (centralCoreForMesh) {
const centralCore = centralCoreForMesh;
const buildAuthHeaders = (): Record<string, string> => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (dashboardAuthToken) {
headers.Authorization = `Bearer ${dashboardAuthToken}`;
}
return headers;
};
tui.setInteractiveData({
listProjects: async () => {
const projects = await centralCore.listProjects();
@@ -1963,6 +1986,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pollIntervalMs: s.pollIntervalMs ?? 60_000,
enginePaused: s.enginePaused ?? false,
globalPause: s.globalPause ?? false,
remoteEnabled: Boolean(s.remoteEnabled),
remoteActiveProvider: (s.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
remoteShortLivedEnabled: Boolean(s.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(s.remoteShortLivedTtlMs ?? 900_000),
};
},
updateSettings: async (partial) => {
@@ -1975,6 +2002,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (partial.pollIntervalMs !== undefined) mapped.pollIntervalMs = partial.pollIntervalMs;
if (partial.enginePaused !== undefined) mapped.enginePaused = partial.enginePaused;
if (partial.globalPause !== undefined) mapped.globalPause = partial.globalPause;
if (partial.remoteEnabled !== undefined) mapped.remoteEnabled = partial.remoteEnabled;
if (partial.remoteActiveProvider !== undefined) mapped.remoteActiveProvider = partial.remoteActiveProvider;
if (partial.remoteShortLivedEnabled !== undefined) mapped.remoteShortLivedEnabled = partial.remoteShortLivedEnabled;
if (partial.remoteShortLivedTtlMs !== undefined) mapped.remoteShortLivedTtlMs = partial.remoteShortLivedTtlMs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await store.updateSettings(mapped as any);
},
@@ -1986,6 +2017,77 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
contextWindow: m.contextWindow ?? 0,
}));
},
remote: {
getStatus: async () => {
const response = await fetch(`${baseUrl}/api/remote/status`, { headers: buildAuthHeaders() });
if (!response.ok) {
throw new Error(`Remote status request failed: ${response.status}`);
}
return await response.json();
},
activateProvider: async (provider: "tailscale" | "cloudflare") => {
const response = await fetch(`${baseUrl}/api/remote/provider/activate`, {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ provider }),
});
if (!response.ok) {
throw new Error(`Remote provider activation failed: ${response.status}`);
}
},
start: async () => {
const response = await fetch(`${baseUrl}/api/remote/tunnel/start`, {
method: "POST",
headers: buildAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Remote start failed: ${response.status}`);
}
},
stop: async () => {
const response = await fetch(`${baseUrl}/api/remote/tunnel/stop`, {
method: "POST",
headers: buildAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Remote stop failed: ${response.status}`);
}
},
regeneratePersistentToken: async () => {
const response = await fetch(`${baseUrl}/api/remote/token/persistent/regenerate`, {
method: "POST",
headers: buildAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Persistent token regeneration failed: ${response.status}`);
}
},
generateShortLivedToken: async (ttlMs: number) => {
const response = await fetch(`${baseUrl}/api/remote/token/short-lived/generate`, {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ttlMs }),
});
if (!response.ok) {
throw new Error(`Short-lived token generation failed: ${response.status}`);
}
return await response.json();
},
fetchUrl: async () => {
const response = await fetch(`${baseUrl}/api/remote/url`, { headers: buildAuthHeaders() });
if (!response.ok) {
throw new Error(`Remote URL request failed: ${response.status}`);
}
return await response.json();
},
fetchQr: async () => {
const response = await fetch(`${baseUrl}/api/remote/qr?format=image%2Fsvg`, { headers: buildAuthHeaders() });
if (!response.ok) {
throw new Error(`Remote QR request failed: ${response.status}`);
}
return await response.json();
},
},
git: {
getStatus: (projectPath: string) => buildGitStatus(projectPath),
listCommits: (projectPath: string, limit?: number) => buildGitCommits(projectPath, limit),