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