feat: redesign remote tunnel settings and named tunnel workflow (#546)

* feat: add Cloudflare Tunnel settings for desktop app

Add a 'Remote Tunnel' section in Settings (desktop-only) that lets users
start/stop a Cloudflare quick tunnel on demand, with auto-generated
password protection and a QR code for easy mobile access.

- Server: 4 new API endpoints (check/status/start/stop) reusing the
  existing cloudflare-tunnel module
- UI: TunnelSettings component with full state machine
  (checking → idle/not-available → starting → active → stopping)
- QR code rendered via the qrcode package for in-app display
- Hidden from VS Code extension (desktop/web only)

* fix: use ?token= instead of ?p= in tunnel password URLs

REST API endpoints were building passwordUrl with ?p=<token> but
SessionAuthGate reads the ?token= query param, causing QR code
auto-login to fail — the password was never extracted from the URL.

Standardize all three tunnel URL construction sites to use ?token=
so scanning the QR code correctly pre-fills and submits the password.

* feat: secure remote tunnel access with one-time connect links

* feat: redesign remote tunnel settings and access flow

* fix: cleaned up unused desktop close code path

* feat: overhaul named tunnel setup and persistence flow

* chore: align codemirror language dependency resolution

---------

Co-authored-by: Brian-Hwang <brian.hwang@cornelisnetworks.com>
This commit is contained in:
Iuliia Ivashko
2026-02-28 04:21:46 +02:00
committed by GitHub
co-authored by Brian-Hwang
parent a505378d79
commit d5d0d35083
15 changed files with 2853 additions and 150 deletions
+15
View File
@@ -29,6 +29,12 @@ export type SkillCatalogConfig = {
gitIdentityId?: string;
};
export type NamedTunnelPreset = {
id: string;
name: string;
hostname: string;
};
export type DesktopSettings = {
themeId?: string;
useSystemTheme?: boolean;
@@ -84,6 +90,15 @@ export type DesktopSettings = {
}>; // Per-provider custom model groups configuration
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
tunnelMode?: 'quick' | 'named';
tunnelBootstrapTtlMs?: number | null;
tunnelSessionTtlMs?: number;
namedTunnelHostname?: string;
namedTunnelToken?: string | null;
hasNamedTunnelToken?: boolean;
namedTunnelPresets?: NamedTunnelPreset[];
namedTunnelSelectedPresetId?: string;
namedTunnelPresetTokens?: Record<string, string>;
defaultModel?: string; // format: "provider/model"
defaultVariant?: string;
defaultAgent?: string;
+79
View File
@@ -208,6 +208,51 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
return result.length > 0 ? result : undefined;
};
const sanitizeNamedTunnelPresets = (value: unknown): DesktopSettings['namedTunnelPresets'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['namedTunnelPresets']> = [];
const seenIds = new Set<string>();
const seenHostnames = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : '';
if (!id || !name || !hostname) continue;
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
seenIds.add(id);
seenHostnames.add(hostname);
result.push({ id, name, hostname });
}
return result;
};
const sanitizeNamedTunnelPresetTokens = (value: unknown): DesktopSettings['namedTunnelPresetTokens'] | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const candidate = value as Record<string, unknown>;
const result: Record<string, string> = {};
for (const [key, tokenValue] of Object.entries(candidate)) {
const id = key.trim();
const token = typeof tokenValue === 'string' ? tokenValue.trim() : '';
if (!id || !token) continue;
result[id] = token;
}
return Object.keys(result).length > 0 ? result : undefined;
};
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
if (!Array.isArray(value)) {
return undefined;
@@ -444,6 +489,40 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
}
if (typeof candidate.tunnelMode === 'string') {
const mode = candidate.tunnelMode.trim().toLowerCase();
if (mode === 'quick' || mode === 'named') {
result.tunnelMode = mode;
}
}
if (candidate.tunnelBootstrapTtlMs === null) {
result.tunnelBootstrapTtlMs = null;
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs;
}
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs;
}
if (typeof candidate.namedTunnelHostname === 'string') {
result.namedTunnelHostname = candidate.namedTunnelHostname.trim();
}
if (candidate.namedTunnelToken === null) {
result.namedTunnelToken = null;
} else if (typeof candidate.namedTunnelToken === 'string') {
result.namedTunnelToken = candidate.namedTunnelToken.trim();
}
const namedTunnelPresets = sanitizeNamedTunnelPresets(candidate.namedTunnelPresets);
if (namedTunnelPresets) {
result.namedTunnelPresets = namedTunnelPresets;
}
if (typeof candidate.namedTunnelSelectedPresetId === 'string') {
const trimmed = candidate.namedTunnelSelectedPresetId.trim();
result.namedTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined;
}
const namedTunnelPresetTokens = sanitizeNamedTunnelPresetTokens(candidate.namedTunnelPresetTokens);
if (namedTunnelPresetTokens) {
result.namedTunnelPresetTokens = namedTunnelPresetTokens;
}
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
result.defaultModel = candidate.defaultModel;
}
+3 -1
View File
@@ -17,7 +17,8 @@ export type SettingsPageSlug =
| 'shortcuts'
| 'sessions'
| 'notifications'
| 'voice';
| 'voice'
| 'tunnel';
export type SettingsPageGroup =
| 'appearance'
@@ -168,6 +169,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'Remote Tunnel', group: 'advanced', kind: 'single', keywords: ['tunnel', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
] as const;
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {