feat(FN-2976): detect and display external Tailscale funnel tunnels in remo

Merges external Tailscale funnel detection (FN-2976) — adds types, detection logic, status inclusion, kill flow, and a dedicated UI panel for funnel processes started outside Fusion — alongside custom AI providers API routes and a new settings UI section (FN-2965).

Fusion-Task-Id: FN-2976
This commit is contained in:
Fusion
2026-04-30 05:41:25 -07:00
committed by gsxdsm
parent 1b3e68740e
commit 48f2dd0de2
16 changed files with 506 additions and 8 deletions

View File

@@ -473,6 +473,10 @@ export interface RemoteStatus {
lastError: string | null;
lastErrorCode?: string | null;
cloudflaredAvailable?: boolean | null;
externalTunnel?: {
provider: "tailscale" | "cloudflare";
url: string | null;
} | null;
restore?: {
outcome: "applied" | "skipped" | "failed";
reason: string;
@@ -525,6 +529,12 @@ export function stopRemoteTunnel(projectId?: string): Promise<{ state: "stopped"
});
}
export function killExternalTunnel(projectId?: string): Promise<{ ok: boolean }> {
return api<{ ok: boolean }>(withProjectId("/remote/tunnel/kill-external", projectId), {
method: "POST",
});
}
export function regenerateRemotePersistentToken(projectId?: string): Promise<{ token: string; maskedToken: string }> {
return api<{ token: string; maskedToken: string }>(withProjectId("/remote/token/persistent/regenerate", projectId), {
method: "POST",

View File

@@ -691,6 +691,36 @@
color: var(--text-dim);
}
.remote-external-tunnel-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0 var(--space-xl) var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-info) 10%, transparent);
}
.remote-external-tunnel-header {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-info);
}
.remote-external-tunnel-qr {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.remote-external-tunnel-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.remote-advanced-details {
margin: var(--space-lg) var(--space-xl) 0;
border: var(--btn-border-width) solid var(--border);
@@ -871,6 +901,10 @@
.remote-cli-detection {
margin: 0 var(--space-lg) var(--space-md);
}
.remote-external-tunnel-panel {
margin: 0 var(--space-lg) var(--space-md);
}
}
/* === Notifications Settings === */

View File

@@ -10,7 +10,7 @@ import {
resolveTitleSummarizerSettingsModel,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -425,6 +425,7 @@ export function SettingsModal({
// Remote access state
const [remoteStatus, setRemoteStatus] = useState<RemoteStatus | null>(null);
const [externalTunnel, setExternalTunnel] = useState<{ provider: string; url: string | null } | null>(null);
const [remoteBusyAction, setRemoteBusyAction] = useState<string | null>(null);
const [cloudflaredInstalling, setCloudflaredInstalling] = useState(false);
const [cloudflaredInstallError, setCloudflaredInstallError] = useState<string | null>(null);
@@ -668,9 +669,17 @@ export function SettingsModal({
if (statusResult.status === "fulfilled") {
setRemoteStatus(statusResult.value);
setExternalTunnel(statusResult.value.externalTunnel ?? null);
}
}, [projectId]);
useEffect(() => {
const state = remoteStatus?.state;
if (state === "running" || state === "starting") {
setExternalTunnel(null);
}
}, [remoteStatus?.state]);
useEffect(() => {
if (activeSection !== "remote") {
return;
@@ -689,7 +698,12 @@ export function SettingsModal({
const state = remoteStatus?.state;
if (state !== "starting" && state !== "stopping") return;
const interval = setInterval(() => {
fetchRemoteStatus(projectId).then(setRemoteStatus).catch(() => {});
fetchRemoteStatus(projectId)
.then((status) => {
setRemoteStatus(status);
setExternalTunnel(status.externalTunnel ?? null);
})
.catch(() => {});
}, 1000);
return () => clearInterval(interval);
}, [activeSection, projectId, remoteStatus?.state]);
@@ -724,6 +738,30 @@ export function SettingsModal({
};
}, [activeSection, projectId, remoteStatus?.state, remoteStatus?.url]);
useEffect(() => {
if (activeSection !== "remote") return;
if (remoteStatus?.state !== "stopped" || !externalTunnel?.url) {
return;
}
let cancelled = false;
(async () => {
try {
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
if (cancelled) return;
setTunnelShareLink({ url: externalTunnel.url, qrSvg: qr.data ?? null });
} catch {
if (!cancelled) {
setTunnelShareLink({ url: externalTunnel.url, qrSvg: null });
}
}
})();
return () => {
cancelled = true;
};
}, [activeSection, externalTunnel?.url, projectId, remoteStatus?.state]);
// Lazy-load git remotes for the rebase-remote dropdown when the Worktrees
// section becomes visible. Failure is non-fatal: the dropdown falls back
// to just "Use git default".
@@ -4039,6 +4077,25 @@ export function SettingsModal({
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
</div>
{tunnelState === "stopped" && externalTunnel && (
<div className="remote-external-tunnel-panel" role="status">
<div className="remote-external-tunnel-header">
<Globe aria-hidden="true" />
<strong>External {externalTunnel.provider} tunnel detected</strong>
</div>
{externalTunnel.url && <code className="settings-url-output">{externalTunnel.url}</code>}
{tunnelShareLink?.qrSvg && (
<div className="remote-external-tunnel-qr">
<small>Scan to open:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="External tunnel QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
)}
{tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => {
let accessCode: string | null = null;
let tailnetUrl: string | null = remoteStatus?.url ?? null;
@@ -4178,6 +4235,57 @@ export function SettingsModal({
</button>
) : (
<>
{externalTunnel ? (
<div className="remote-external-tunnel-actions">
<button type="button" className="btn" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start fresh", async () => {
const formState = form as Record<string, unknown>;
const savePayload: Partial<RemoteSettings> = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
};
await updateRemoteSettings(savePayload, projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast("Remote tunnel restarted", "success");
})}>
{remoteBusyAction === "start fresh" ? "Restarting…" : "Start Fresh"}
</button>
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("use existing", async () => {
const formState = form as Record<string, unknown>;
const savePayload: Partial<RemoteSettings> = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
};
await updateRemoteSettings(savePayload, projectId);
await startRemoteTunnel(projectId);
addToast("Remote tunnel started", "success");
})}>
{remoteBusyAction === "use existing" ? "Starting…" : "Use Existing"}
</button>
</div>
) : (
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
const formState = form as Record<string, unknown>;
const savePayload: Partial<RemoteSettings> = {
@@ -4204,6 +4312,7 @@ export function SettingsModal({
})}>
{remoteBusyAction === "start" ? "Starting…" : "Start Tunnel"}
</button>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
<small className="field-error">cloudflared must be installed to start the tunnel</small>
) : null}

View File

@@ -42,6 +42,7 @@ const mockFetchRemoteStatus = vi.fn();
const mockInstallCloudflared = vi.fn();
const mockStartRemoteTunnel = vi.fn();
const mockStopRemoteTunnel = vi.fn();
const mockKillExternalTunnel = vi.fn();
const mockRegenerateRemotePersistentToken = vi.fn();
const mockGenerateShortLivedRemoteToken = vi.fn();
const mockFetchRemoteQr = vi.fn();
@@ -87,6 +88,7 @@ vi.mock("../../api", () => ({
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
@@ -302,6 +304,7 @@ describe("SettingsModal", () => {
mockInstallCloudflared.mockResolvedValue({ success: true, command: "brew install cloudflared" });
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
mockKillExternalTunnel.mockResolvedValue({ ok: true });
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
@@ -1932,6 +1935,46 @@ describe("SettingsModal", () => {
});
});
it("shows external tunnel panel with actions when external tunnel is detected", async () => {
mockFetchRemoteStatus.mockResolvedValue({
provider: "tailscale",
state: "stopped",
url: null,
lastError: null,
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
});
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
expect(await screen.findByText("External tailscale tunnel detected")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Start Fresh" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Use Existing" })).toBeInTheDocument();
});
it("Start Fresh kills external tunnel before starting", async () => {
mockFetchRemoteStatus.mockResolvedValue({
provider: "tailscale",
state: "stopped",
url: null,
lastError: null,
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
});
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
await userEvent.click(screen.getByLabelText("Tailscale"));
await userEvent.click(await screen.findByRole("button", { name: "Start Fresh" }));
await waitFor(() => {
expect(mockKillExternalTunnel).toHaveBeenCalledWith(undefined);
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
});
});
it("regenerates persistent token and surfaces success feedback without exposing raw token text", async () => {
const addToast = vi.fn();
renderModal({ addToast });

View File

@@ -251,6 +251,49 @@ describe("remote access provider/lifecycle contracts", () => {
}));
});
it("returns 200 for remote status when managed tunnel is stopped", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
}),
});
const { app } = createApp({ store });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({ state: "stopped" }));
});
it("omits externalTunnel detection when managed tunnel is running", async () => {
const engine = {
getRemoteTunnelManager: vi.fn().mockReturnValue({
getStatus: vi.fn().mockReturnValue({ state: "running", provider: "tailscale", url: "https://live.ts.net/", lastError: null }),
}),
getRemoteTunnelRestoreDiagnostics: vi.fn().mockReturnValue(null),
detectExternalTunnel: vi.fn(),
};
const { app } = createApp({ engine });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body.externalTunnel).toBeNull();
expect(engine.detectExternalTunnel).not.toHaveBeenCalled();
});
it("calls engine killExternalTunnel via kill-external endpoint", async () => {
const engine = {
killExternalTunnel: vi.fn().mockResolvedValue(undefined),
};
const { app } = createApp({ engine });
const result = await REQUEST(app, "POST", "/api/remote/tunnel/kill-external", {});
expect(result.status).toBe(200);
expect(result.body).toEqual({ ok: true });
});
it("installs cloudflared via endpoint and returns install command metadata", async () => {
const { app } = createApp();

View File

@@ -380,8 +380,8 @@ describe("createServer health and headless mode", () => {
const dashStatusBody = dashStatus.body as Record<string, unknown>;
const headlessStatusBody = headlessStatus.body as Record<string, unknown>;
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(headlessRoot.status).toBe(404);
});
});

View File

@@ -494,18 +494,29 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
const restore = engine?.getRemoteTunnelRestoreDiagnostics();
const activeProvider = tunnelStatus?.provider ?? settings.remoteAccess?.activeProvider ?? null;
const tunnelState = tunnelStatus?.state ?? "stopped";
let cloudflaredAvailable: boolean | null = null;
if (activeProvider === "cloudflare") {
cloudflaredAvailable = await isCloudflaredAvailable();
}
const externalTunnel = tunnelState === "stopped"
? await engine?.detectExternalTunnel()
: null;
res.json({
provider: activeProvider,
state: tunnelStatus?.state ?? "stopped",
state: tunnelState,
url: tunnelStatus?.url ?? null,
lastError: tunnelStatus?.lastError?.message ?? null,
lastErrorCode: tunnelStatus?.lastError?.code ?? null,
cloudflaredAvailable,
externalTunnel: externalTunnel
? {
provider: externalTunnel.provider,
url: externalTunnel.url,
}
: null,
restore: restore ?? {
outcome: "skipped",
reason: "not_attempted",
@@ -634,6 +645,19 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
});
router.post("/remote/tunnel/kill-external", async (req, res) => {
try {
const { engine } = await getProjectContext(req);
if (engine) {
await engine.killExternalTunnel();
}
res.json({ ok: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to kill external remote tunnel");
}
});
router.post("/remote/token/persistent/regenerate", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);