diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 1d7778f5..dc75b157 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -30,7 +30,7 @@ import { seedReposConfig } from "./seed-repos-config.js"; import { ActivityLogStore } from "./activity-log-store.js"; import { ApprovalStore } from "./approval-store.js"; import type { GatewayApprovalRequest, GatewayApprovalResult } from "../server/router.js"; -import { normalizeAndValidateApiOrigin, normalizeWebAppOrigin } from "./origin-policy.js"; +import { normalizeAndValidateOrigin, normalizeWebAppOrigin } from "./origin-policy.js"; import { LocalSessionStore } from "./local-session-store.js"; import pkg from "electron-updater"; const { autoUpdater } = pkg; @@ -97,7 +97,8 @@ export class DesktopApplication { () => this.getSymphonyDir(), this.sessionStore, () => this.apiKeyStore.getApiKey(), - () => this.settingsStore.getApiOrigin() + () => this.settingsStore.getApiOrigin(), + () => this.settingsStore.getWebAppOrigin() ); this.commandExecutor = new CloudCommandExecutor({ getGatewayPort: () => this.server.getActivePort(), @@ -117,7 +118,7 @@ export class DesktopApplication { } }); this.cloudSocket = new CloudSocketService({ - getApiOrigin: () => this.settingsStore.getApiOrigin(), + getRelayOrigin: () => this.settingsStore.getRelayOrigin(), getApiKey: () => this.apiKeyStore.getApiKey(), getAllowedDirectories: () => this.getAllowedDirectoriesFromSandbox(), getMaxInFlightCommands: () => MAX_IN_FLIGHT_COMMANDS, @@ -200,11 +201,12 @@ export class DesktopApplication { try { await this.server.start(); const configuredOrigins = { + relayOrigin: this.settingsStore.getRelayOrigin(), apiOrigin: this.settingsStore.getApiOrigin(), webAppOrigin: this.settingsStore.getWebAppOrigin() }; this.refreshTrayState( - `Serving on localhost:${this.server.getActivePort()} | api=${configuredOrigins.apiOrigin} web=${configuredOrigins.webAppOrigin}` + `Serving on localhost:${this.server.getActivePort()} | relay=${configuredOrigins.relayOrigin} api=${configuredOrigins.apiOrigin} web=${configuredOrigins.webAppOrigin}` ); if (this.cloudConnectionEnabled) { @@ -654,6 +656,7 @@ export class DesktopApplication { async (_event, partial: { sandboxBaseDirectory?: string; onboardingCompleted?: boolean; + relayOrigin?: string; apiOrigin?: string; webAppOrigin?: string; defaultApprovalTier?: "auto" | "low" | "medium" | "high"; @@ -661,8 +664,11 @@ export class DesktopApplication { }) => { const currentSettings = this.settingsStore.getAll(); const nextPartial = { ...partial }; + if (typeof partial.relayOrigin === "string") { + nextPartial.relayOrigin = normalizeAndValidateOrigin(partial.relayOrigin); + } if (typeof partial.apiOrigin === "string") { - nextPartial.apiOrigin = normalizeAndValidateApiOrigin(partial.apiOrigin); + nextPartial.apiOrigin = normalizeAndValidateOrigin(partial.apiOrigin); } if (typeof partial.webAppOrigin === "string") { nextPartial.webAppOrigin = normalizeWebAppOrigin(partial.webAppOrigin); @@ -702,6 +708,7 @@ export class DesktopApplication { ipcMain.handle("desktop:get-runtime-status", () => ({ port: this.server.getActivePort(), cloudStatus: this.cloudStatus, + relayOrigin: this.settingsStore.getRelayOrigin(), apiOrigin: this.settingsStore.getApiOrigin(), sandboxBaseDirectory: this.settingsStore.getSandboxBaseDirectory(), commandsPaused: this.cloudCommandsPaused, @@ -794,13 +801,19 @@ export class DesktopApplication { async ( _event, payload: { - apiOrigin: string; + relayOrigin?: string; + apiOrigin?: string; webAppOrigin: string; sandboxBaseDirectory: string; apiKey?: string; } ) => { - const apiOrigin = normalizeAndValidateApiOrigin(payload.apiOrigin); + const relayOrigin = typeof payload.relayOrigin === "string" && payload.relayOrigin.trim() + ? normalizeAndValidateOrigin(payload.relayOrigin) + : undefined; + const apiOrigin = typeof payload.apiOrigin === "string" && payload.apiOrigin.trim() + ? normalizeAndValidateOrigin(payload.apiOrigin) + : undefined; const webAppOrigin = normalizeWebAppOrigin(payload.webAppOrigin); const sandboxBaseDirectory = normalizeScopePath(payload.sandboxBaseDirectory); if (!sandboxBaseDirectory) { @@ -816,7 +829,8 @@ export class DesktopApplication { } this.settingsStore.update({ - apiOrigin, + ...(relayOrigin !== undefined ? { relayOrigin } : {}), + ...(apiOrigin !== undefined ? { apiOrigin } : {}), webAppOrigin, sandboxBaseDirectory, onboardingCompleted: true diff --git a/apps/desktop/src/main/cloud-socket.ts b/apps/desktop/src/main/cloud-socket.ts index b96d891f..95292b67 100644 --- a/apps/desktop/src/main/cloud-socket.ts +++ b/apps/desktop/src/main/cloud-socket.ts @@ -13,10 +13,10 @@ import { type DesktopHelloEvent, type DesktopPresenceEvent } from "./cloud-protocol.js"; -import { normalizeAndValidateApiOrigin } from "./origin-policy.js"; +import { normalizeAndValidateOrigin } from "./origin-policy.js"; export interface CloudSocketOptions { - getApiOrigin: () => string; + getRelayOrigin: () => string; getApiKey: () => string | null; getAllowedDirectories: () => string[]; getMaxInFlightCommands: () => number; @@ -55,17 +55,17 @@ export class CloudSocketService { return; } - let apiOrigin: string; + let relayOrigin: string; try { - apiOrigin = normalizeAndValidateApiOrigin(this.options.getApiOrigin()); + relayOrigin = normalizeAndValidateOrigin(this.options.getRelayOrigin()); } catch (error) { - const message = error instanceof Error ? error.message : "invalid API origin"; + const message = error instanceof Error ? error.message : "invalid relay origin"; this.notifyStatus({ state: "degraded", error: message }); return; } this.notifyStatus({ state: "idle" }); - this.connect(apiKey, apiOrigin); + this.connect(apiKey, relayOrigin); } stop(): void { @@ -111,8 +111,8 @@ export class CloudSocketService { } } - private connect(apiKey: string, apiOrigin: string): void { - const socket = io(`${apiOrigin}/desktop-gateway`, { + private connect(apiKey: string, relayOrigin: string): void { + const socket = io(`${relayOrigin}/desktop-gateway`, { transports: ["websocket"], reconnection: true, reconnectionDelay: 1000, diff --git a/apps/desktop/src/main/origin-policy.ts b/apps/desktop/src/main/origin-policy.ts index c71370f8..f93f30c6 100644 --- a/apps/desktop/src/main/origin-policy.ts +++ b/apps/desktop/src/main/origin-policy.ts @@ -1,16 +1,16 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); -export function normalizeAndValidateApiOrigin(rawOrigin: string): string { +export function normalizeAndValidateOrigin(rawOrigin: string): string { const trimmed = rawOrigin.trim(); if (!trimmed) { - throw new Error("API origin is required"); + throw new Error("Origin is required"); } let parsed: URL; try { parsed = new URL(trimmed); } catch { - throw new Error("API origin must be a valid URL"); + throw new Error("Origin must be a valid URL"); } if (parsed.protocol === "https:") { @@ -22,10 +22,13 @@ export function normalizeAndValidateApiOrigin(rawOrigin: string): string { } throw new Error( - "API origin must use https (http is allowed only for localhost/127.0.0.1 in local development)" + "Origin must use https (http is allowed only for localhost/127.0.0.1 in local development)" ); } +/** @deprecated Use normalizeAndValidateOrigin instead. */ +export const normalizeAndValidateApiOrigin = normalizeAndValidateOrigin; + export function normalizeWebAppOrigin(rawOrigin: string): string { const trimmed = rawOrigin.trim(); if (!trimmed) { diff --git a/apps/desktop/src/main/settings-store.ts b/apps/desktop/src/main/settings-store.ts index f6ce5a25..c9423525 100644 --- a/apps/desktop/src/main/settings-store.ts +++ b/apps/desktop/src/main/settings-store.ts @@ -5,6 +5,7 @@ import { type DesktopSettings, type RiskTier } from "../shared/contracts.js"; +import { normalizeAndValidateOrigin } from "./origin-policy.js"; export interface SettingsStoreOptions { cwd?: string; @@ -17,8 +18,7 @@ export class SettingsStore { constructor(options?: SettingsStoreOptions) { this.store = new Store({ name: options?.name ?? "desktop-settings", - cwd: options?.cwd, - defaults: DEFAULT_DESKTOP_SETTINGS + cwd: options?.cwd }); // Migration: delete stale allowedDirectories key from previous versions. @@ -27,12 +27,55 @@ export class SettingsStore { if ("allowedDirectories" in this.store.store) { this.store.delete("allowedDirectories" as keyof DesktopSettings); } + + // Migration: rename apiOrigin → relayOrigin, preserve authApiOrigin → apiOrigin. + // With defaults removed, this.store.store only contains actually-persisted keys, + // so key-presence checks are reliable. + const raw = this.store.store as unknown as Record; + const hadRelayOrigin = "relayOrigin" in raw; + const hadAuthApiOrigin = "authApiOrigin" in raw; + const oldApiOrigin = raw.apiOrigin as string | undefined; + const oldAuthApiOrigin = raw.authApiOrigin as string | undefined; + + if (!hadRelayOrigin && typeof oldApiOrigin === "string") { + // Legacy: apiOrigin held the relay URL. Move it to relayOrigin. + let relayOrigin = DEFAULT_DESKTOP_SETTINGS.relayOrigin; + try { + relayOrigin = normalizeAndValidateOrigin(oldApiOrigin); + } catch { + // Fall back to default on invalid value + } + this.store.set("relayOrigin" as keyof DesktopSettings, relayOrigin); + + if (hadAuthApiOrigin && typeof oldAuthApiOrigin === "string") { + // Intermediate build: authApiOrigin held the REST API URL. Promote it. + let apiOrigin = DEFAULT_DESKTOP_SETTINGS.apiOrigin; + try { + apiOrigin = normalizeAndValidateOrigin(oldAuthApiOrigin); + } catch { + // Fall back to default on invalid value + } + this.store.set("apiOrigin" as keyof DesktopSettings, apiOrigin); + } else { + // Pre-auth install: no REST API origin was ever set. Use default. + this.store.set("apiOrigin" as keyof DesktopSettings, DEFAULT_DESKTOP_SETTINGS.apiOrigin); + } + } + + // Always clean up stale authApiOrigin key (intermediate build artifact). + if (hadAuthApiOrigin) { + this.store.delete("authApiOrigin" as keyof DesktopSettings); + } } getAll(): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, ...this.store.store }; } + getRelayOrigin(): string { + return this.store.get("relayOrigin" as keyof DesktopSettings, DEFAULT_DESKTOP_SETTINGS.relayOrigin) as string; + } + getApiOrigin(): string { return this.store.get("apiOrigin", DEFAULT_DESKTOP_SETTINGS.apiOrigin); } @@ -81,6 +124,10 @@ export class SettingsStore { this.store.set("defaultApprovalTier", defaultApprovalTier); } + setRelayOrigin(relayOrigin: string): void { + this.store.set("relayOrigin" as keyof DesktopSettings, relayOrigin); + } + setApiOrigin(apiOrigin: string): void { this.store.set("apiOrigin", apiOrigin); } @@ -116,6 +163,9 @@ export class SettingsStore { if (typeof partial.cloudConnectionEnabled === "boolean") { this.store.set("cloudConnectionEnabled", partial.cloudConnectionEnabled); } + if (typeof partial.relayOrigin === "string") { + this.store.set("relayOrigin" as keyof DesktopSettings, partial.relayOrigin); + } if (typeof partial.apiOrigin === "string") { this.store.set("apiOrigin", partial.apiOrigin); } diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index aa7c50ad..0b65ae5d 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -939,6 +939,16 @@ border-radius: 14px; padding: 22px; } + + .settings-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + + @media (max-width: 768px) { + .settings-columns { grid-template-columns: 1fr; } + } @@ -973,12 +983,19 @@

ClosedLoop Gateway