From b38b8f6f420b146cc16e4c41ac329c4b633b670a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:35:35 -0400 Subject: [PATCH 1/4] Add durable authenticated preview handoff --- packages/desktop-app/shared/app-registry.ts | 5 + packages/desktop-app/shared/ipc-channels.ts | 14 + .../src/main/app-store.privacy.test.ts | 84 ++- packages/desktop-app/src/main/app-store.ts | 197 ++++++ packages/desktop-app/src/main/index.ts | 612 +++++++++++++++++- packages/desktop-app/src/main/ipc/apps.ts | 99 ++- .../protected-preview-oauth-doorway.test.ts | 181 ++++++ .../main/protected-preview-oauth-doorway.ts | 257 ++++++++ packages/desktop-app/src/preload/index.ts | 16 + packages/desktop-app/src/renderer/App.tsx | 23 +- .../src/renderer/components/AppSettings.tsx | 152 ++++- packages/desktop-app/src/renderer/global.d.ts | 21 + packages/frame/src/server.ts | 29 +- packages/frame/vite.config.ts | 6 +- packages/shared-app-config/index.ts | 13 + .../protected-preview.test.ts | 238 +++++++ .../shared-app-config/protected-preview.ts | 276 ++++++++ 17 files changed, 2180 insertions(+), 43 deletions(-) create mode 100644 packages/desktop-app/src/main/protected-preview-oauth-doorway.test.ts create mode 100644 packages/desktop-app/src/main/protected-preview-oauth-doorway.ts create mode 100644 packages/shared-app-config/protected-preview.test.ts create mode 100644 packages/shared-app-config/protected-preview.ts diff --git a/packages/desktop-app/shared/app-registry.ts b/packages/desktop-app/shared/app-registry.ts index 0a8bc225c4..69d62a8305 100644 --- a/packages/desktop-app/shared/app-registry.ts +++ b/packages/desktop-app/shared/app-registry.ts @@ -118,6 +118,11 @@ export { getAppUrl, getTemplateGatewayAppUrl, getTemplateGatewayUrl, + normalizeProtectedPreviewOrigin, + authorizeProtectedPreviewLaunch, + authorizeProtectedPreviewRequest, + resolveFrameOAuthCallbackTarget, + resolveProtectedPreviewOAuthRelay, getAppById, toAppDefinition, generateAppId, diff --git a/packages/desktop-app/shared/ipc-channels.ts b/packages/desktop-app/shared/ipc-channels.ts index 9b757a4db5..a41fea2f35 100644 --- a/packages/desktop-app/shared/ipc-channels.ts +++ b/packages/desktop-app/shared/ipc-channels.ts @@ -38,6 +38,9 @@ export const IPC = { APPS_UPDATE_CREATION_SETTINGS: "apps:update-creation-settings", APPS_CREATE_FROM_PROMPT: "apps:create-from-prompt", APPS_SHOW_CONTEXT_MENU: "apps:show-context-menu", + PROTECTED_PREVIEW_GET: "protected-preview:get", + PROTECTED_PREVIEW_SAVE: "protected-preview:save", + PROTECTED_PREVIEW_CLEAR: "protected-preview:clear", /** Hosted Plan app local-file sync (Plan webview ↔ main) */ PLAN_FILES_GET_FOLDER: "plan-files:get-folder", @@ -109,6 +112,7 @@ export const IPC = { /** Deep links (main → renderer) */ DEEP_LINK_OPEN: "deep-link:open", + DEEP_LINK_READY: "deep-link:ready", /** Local desktop app-launch shortcuts (renderer ↔ main) */ SHORTCUTS_ACTIVATE: "shortcuts:activate", @@ -171,6 +175,15 @@ export interface DesktopAppCreationSettings { appsRoot: string; } +export interface ProtectedPreviewAccessStatus { + available: boolean; + configured: boolean; + origin?: string; + kind?: "shareable-link"; + restoreApp?: import("@agent-native/shared-app-config").AppConfig; + error?: string; +} + export interface DesktopCreateAppRequest { prompt: string; appsRoot?: string; @@ -811,6 +824,7 @@ export interface DesktopOpenRequest { app?: string; goalId?: string; path?: string; + previewUrl?: string; softOpen?: boolean; runId?: string; } diff --git a/packages/desktop-app/src/main/app-store.privacy.test.ts b/packages/desktop-app/src/main/app-store.privacy.test.ts index ce04db9631..9319a0dd50 100644 --- a/packages/desktop-app/src/main/app-store.privacy.test.ts +++ b/packages/desktop-app/src/main/app-store.privacy.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const electronState = vi.hoisted(() => ({ userData: "", - decryptString: vi.fn(() => "sk-test-example"), + encryptionAvailable: true, + decryptString: vi.fn((value: Buffer) => value.toString()), })); vi.mock("electron", () => ({ @@ -15,16 +16,20 @@ vi.mock("electron", () => ({ getPath: () => electronState.userData, }, safeStorage: { - isEncryptionAvailable: vi.fn(() => true), + isEncryptionAvailable: vi.fn(() => electronState.encryptionAvailable), decryptString: electronState.decryptString, encryptString: vi.fn((value: string) => Buffer.from(value)), }, })); import { + clearProtectedPreviewAccess, getCodeAgentProviderSettingsStatus, + getProtectedPreviewAccessStatus, loadCodeAgentProviderCredentials, + loadProtectedPreviewAccess, loadRemoteConnectorSettings, + saveProtectedPreviewAccess, } from "./app-store"; describe("desktop privacy-safe status reads", () => { @@ -33,6 +38,7 @@ describe("desktop privacy-safe status reads", () => { path.join(os.tmpdir(), "agent-native-privacy-"), ); electronState.decryptString.mockClear(); + electronState.encryptionAvailable = true; fs.writeFileSync( path.join(electronState.userData, "code-agent-providers.json"), JSON.stringify({ @@ -70,4 +76,78 @@ describe("desktop privacy-safe status reads", () => { it("defaults the background connector to disabled", () => { expect(loadRemoteConnectorSettings()).toEqual({ enabled: false }); }); + + it("rejects project-wide automation bypass credentials", () => { + const origin = "https://candidate.example.test"; + + expect( + saveProtectedPreviewAccess( + "content", + `${origin}/review`, + "example-project-wide-secret", + ), + ).toMatchObject({ configured: false }); + }); + + it("extracts a deployment share secret without storing the bearer URL", () => { + const origin = "https://candidate.example.test"; + const secret = "example-share-secret"; + const shareUrl = `${origin}/?_vercel_share=${secret}`; + + expect(saveProtectedPreviewAccess("content", origin, shareUrl)).toEqual({ + available: true, + configured: true, + origin, + kind: "shareable-link", + }); + + const stored = fs.readFileSync( + path.join(electronState.userData, "protected-preview-access.json"), + "utf-8", + ); + expect(stored).toContain('"kind": "shareable-link"'); + expect(stored).not.toContain("_vercel_share"); + expect(stored).not.toContain(secret); + expect(loadProtectedPreviewAccess("content")).toEqual({ + origin, + kind: "shareable-link", + secret, + }); + expect(getProtectedPreviewAccessStatus("content")).toMatchObject({ + available: true, + configured: true, + origin, + kind: "shareable-link", + }); + + expect(clearProtectedPreviewAccess("content")).toEqual({ + available: true, + configured: false, + }); + expect(loadProtectedPreviewAccess("content")).toBeNull(); + }); + + it("rejects a Shareable Link for a different deployment origin", () => { + expect( + saveProtectedPreviewAccess( + "content", + "https://candidate.example.test", + "https://other-candidate.example.test/?_vercel_share=secret", + ), + ).toMatchObject({ configured: false }); + }); + + it("fails closed when operating-system encryption is unavailable", () => { + electronState.encryptionAvailable = false; + expect( + saveProtectedPreviewAccess( + "content", + "https://candidate.example.test", + "https://candidate.example.test/?_vercel_share=example-share-secret", + ), + ).toMatchObject({ + available: false, + configured: false, + }); + }); }); diff --git a/packages/desktop-app/src/main/app-store.ts b/packages/desktop-app/src/main/app-store.ts index e155378fcd..be1dd43254 100644 --- a/packages/desktop-app/src/main/app-store.ts +++ b/packages/desktop-app/src/main/app-store.ts @@ -6,6 +6,7 @@ import { DESKTOP_DEFAULT_APPS, TEMPLATE_APPS, sortDesktopApps, + normalizeProtectedPreviewOrigin, type AppConfig, type FrameSettings, } from "@shared/app-registry"; @@ -20,6 +21,7 @@ import type { CodeAgentProviderSettings, CodeAgentProviderSettingsUpdate, CodeAgentProviderStatus, + ProtectedPreviewAccessStatus, } from "@shared/ipc-channels"; import { app, safeStorage } from "electron"; @@ -27,6 +29,7 @@ const STORE_FILE = "app-config.json"; const FRAME_STORE_FILE = "frame-config.json"; const REMOTE_CONNECTOR_STORE_FILE = "remote-connector-config.json"; const CODE_AGENT_PROVIDER_STORE_FILE = "code-agent-providers.json"; +const PROTECTED_PREVIEW_STORE_FILE = "protected-preview-access.json"; const SHORTCUT_STORE_FILE = "shortcut-config.json"; const DESKTOP_APP_PREFERENCES_STORE_FILE = "desktop-app-preferences.json"; const REMOVED_DESKTOP_APP_IDS = new Set(["starter"]); @@ -41,6 +44,43 @@ interface CodeAgentProviderStore { credentials: Partial>; } +interface ProtectedPreviewStore { + version: 1; + entries: Record< + string, + { + origin: string; + kind?: ProtectedPreviewAccessKind; + secret: StoredSecret; + } + >; +} + +export type ProtectedPreviewAccessKind = "shareable-link"; + +export interface ProtectedPreviewAccess { + origin: string; + kind: ProtectedPreviewAccessKind; + secret: string; +} + +const protectedPreviewAccessEpochs = new Map(); + +function bumpProtectedPreviewAccessEpoch(appId: string): void { + protectedPreviewAccessEpochs.set( + appId, + (protectedPreviewAccessEpochs.get(appId) ?? 0) + 1, + ); +} + +export function getProtectedPreviewAccessEpoch(appId: string): number { + return protectedPreviewAccessEpochs.get(appId) ?? 0; +} + +export function invalidateProtectedPreviewAccess(appId: string): void { + bumpProtectedPreviewAccessEpoch(appId); +} + export interface CodeAgentProviderCredentialApplyResult { ok: boolean; settings: CodeAgentProviderSettings; @@ -178,6 +218,10 @@ function getCodeAgentProviderStorePath(): string { return path.join(app.getPath("userData"), CODE_AGENT_PROVIDER_STORE_FILE); } +function getProtectedPreviewStorePath(): string { + return path.join(app.getPath("userData"), PROTECTED_PREVIEW_STORE_FILE); +} + function getShortcutStorePath(): string { return path.join(app.getPath("userData"), SHORTCUT_STORE_FILE); } @@ -245,6 +289,34 @@ function saveCodeAgentProviderStore(store: CodeAgentProviderStore): void { writeJsonFileAtomic(getCodeAgentProviderStorePath(), store, { mode: 0o600 }); } +let protectedPreviewStoreCache: ProtectedPreviewStore | null = null; + +function defaultProtectedPreviewStore(): ProtectedPreviewStore { + return { version: 1, entries: {} }; +} + +function loadProtectedPreviewStore(): ProtectedPreviewStore { + if (protectedPreviewStoreCache) return protectedPreviewStoreCache; + try { + const raw = JSON.parse( + fs.readFileSync(getProtectedPreviewStorePath(), "utf-8"), + ) as Partial; + protectedPreviewStoreCache = { + version: 1, + entries: + raw.entries && typeof raw.entries === "object" ? raw.entries : {}, + }; + } catch { + protectedPreviewStoreCache = defaultProtectedPreviewStore(); + } + return protectedPreviewStoreCache; +} + +function saveProtectedPreviewStore(store: ProtectedPreviewStore): void { + protectedPreviewStoreCache = store; + writeJsonFileAtomic(getProtectedPreviewStorePath(), store, { mode: 0o600 }); +} + function defaultShortcutStore(): ShortcutStore { return { version: 1, bindings: [] }; } @@ -345,6 +417,126 @@ function decryptProviderSecret( } } +function encryptProtectedPreviewSecret(value: string): StoredSecret | null { + if (!canUseSafeStorage()) return null; + try { + return { + encoding: "safeStorage-v1", + value: safeStorage.encryptString(value).toString("base64"), + updatedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +export function loadProtectedPreviewAccess( + appId: string, +): ProtectedPreviewAccess | null { + const entry = loadProtectedPreviewStore().entries[appId]; + const origin = normalizeProtectedPreviewOrigin(entry?.origin); + if (!entry || !origin || entry.secret.encoding !== "safeStorage-v1") { + return null; + } + const secret = decryptProviderSecret(entry.secret); + return secret && entry.kind === "shareable-link" + ? { + origin, + kind: "shareable-link", + secret, + } + : null; +} + +export function getProtectedPreviewAccessStatus( + appId: string, +): ProtectedPreviewAccessStatus { + const entry = loadProtectedPreviewStore().entries[appId]; + const access = loadProtectedPreviewAccess(appId); + const available = canUseSafeStorage(); + return { + available, + configured: Boolean(access), + ...(entry?.origin ? { origin: entry.origin } : {}), + ...(access ? { kind: access.kind } : {}), + ...(!available + ? { error: "Secure operating-system credential storage is unavailable." } + : entry && !access + ? { error: "The stored preview credential could not be decrypted." } + : {}), + }; +} + +export function saveProtectedPreviewAccess( + appId: string, + originValue: string, + secretValue: string, +): ProtectedPreviewAccessStatus { + const id = appId.trim(); + const origin = normalizeProtectedPreviewOrigin(originValue); + const credential = secretValue.trim(); + if (!id || !origin || !credential) { + return { + available: canUseSafeStorage(), + configured: false, + error: "An app, exact HTTPS preview origin, and credential are required.", + }; + } + let shareUrl: URL; + try { + shareUrl = new URL(credential); + } catch { + return { + available: canUseSafeStorage(), + configured: false, + error: "Enter the deployment-specific Vercel Shareable Link.", + }; + } + const secret = shareUrl.searchParams.get("_vercel_share")?.trim(); + if (shareUrl.protocol !== "https:" || shareUrl.origin !== origin || !secret) { + return { + available: canUseSafeStorage(), + configured: false, + error: + "The Shareable Link must target this exact HTTPS preview origin and include _vercel_share.", + }; + } + const encrypted = encryptProtectedPreviewSecret(secret); + if (!encrypted) { + return { + available: false, + configured: false, + error: "Secure operating-system credential storage is unavailable.", + }; + } + const store = loadProtectedPreviewStore(); + saveProtectedPreviewStore({ + version: 1, + entries: { + ...store.entries, + [id]: { origin, kind: "shareable-link", secret: encrypted }, + }, + }); + bumpProtectedPreviewAccessEpoch(id); + return { + available: true, + configured: true, + origin, + kind: "shareable-link", + }; +} + +export function clearProtectedPreviewAccess( + appId: string, +): ProtectedPreviewAccessStatus { + const store = loadProtectedPreviewStore(); + const entries = { ...store.entries }; + delete entries[appId]; + saveProtectedPreviewStore({ version: 1, entries }); + bumpProtectedPreviewAccessEpoch(appId); + return { available: canUseSafeStorage(), configured: false }; +} + function migrateDecryptableProviderSecrets( store: CodeAgentProviderStore, credentials: Partial>, @@ -781,6 +973,7 @@ export function removeApp(id: string): AppConfig[] { managedAppIds: preferences.managedAppIds.filter((appId) => appId !== id), appOrder: preferences.appOrder.filter((appId) => appId !== id), }); + clearProtectedPreviewAccess(id); return apps; } @@ -814,8 +1007,12 @@ export function updateApp( } export function resetToDefaults(): AppConfig[] { + for (const appId of Object.keys(loadProtectedPreviewStore().entries)) { + bumpProtectedPreviewAccessEpoch(appId); + } const apps = defaultApps(); saveApps(apps); saveDesktopAppPreferences({ managedAppIds: [], appOrder: [] }); + saveProtectedPreviewStore(defaultProtectedPreviewStore()); return apps; } diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index f3c86f8b64..3cc63cdb74 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -14,9 +14,12 @@ import { fileURLToPath } from "url"; import { FRAME_PORT, + authorizeProtectedPreviewLaunch, getDesktopTemplateGatewayAppUrl, getTemplate, isDefaultDesktopTemplateDevTarget, + resolveFrameOAuthCallbackTarget, + resolveProtectedPreviewOAuthRelay, } from "@shared/app-registry"; import type { AppConfig } from "@shared/app-registry"; import { @@ -163,6 +166,7 @@ import { registerUpdatesIpc, } from "./ipc/updates"; import { registerWindowIpc } from "./ipc/window"; +import { ProtectedPreviewOAuthDoorway } from "./protected-preview-oauth-doorway"; import { initializeDesktopSentry, installSentryWebContentsInstrumentation, @@ -229,8 +233,9 @@ if (IS_DEV) { app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL); } -let pendingDeepLink: string | null = null; +const pendingDeepLinks: string[] = []; let mainWindow: BrowserWindow | null = null; +let deepLinkReadyWebContentsId: number | null = null; let desktopDesignPreviewManager: DesktopDesignPreviewManager | null = null; let desktopComputerMcpBridge: DesktopComputerMcpBridge | null = null; let desktopBrowserControlBridge: BrowserControlLoopbackBridge | null = null; @@ -325,10 +330,17 @@ function isDeepLinkArg(arg: string): boolean { return arg.startsWith(`${DEEP_LINK_PROTOCOL}:`); } +const initialDeepLink = process.argv.find(isDeepLinkArg); +if (initialDeepLink) pendingDeepLinks.push(initialDeepLink); + function handleSecondInstance(_event: Electron.Event, argv: string[]): void { const deepLink = argv.find(isDeepLinkArg); if (deepLink) { - void handleDeepLink(deepLink); + if (app.isReady()) { + void handleDeepLink(deepLink); + } else { + pendingDeepLinks.push(deepLink); + } } else { focusMainWindow(); } @@ -367,6 +379,13 @@ interface PendingOAuthState extends OAuthInjectionTarget { } const pendingOAuthStates = new Map(); +const protectedPreviewOAuthFlows = new Map< + string, + { flowId: string; origin: string; accessEpoch: number } +>(); +const protectedPreviewOAuthDoorway = new ProtectedPreviewOAuthDoorway(); +const PROTECTED_PREVIEW_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const PROTECTED_PREVIEW_OAUTH_POLL_MS = 750; function prunePendingOAuthStates(now = Date.now()) { for (const [state, pending] of pendingOAuthStates) { @@ -561,7 +580,14 @@ function consumeOAuthState(state: string | null): OAuthInjectionTarget | null { } function flushPendingOpenRequests(win = mainWindow) { - if (!win || win.isDestroyed() || win.webContents.isLoading()) return; + if ( + !win || + win.isDestroyed() || + win.webContents.isLoading() || + deepLinkReadyWebContentsId !== win.webContents.id + ) { + return; + } while (pendingOpenRequests.length > 0) { const request = pendingOpenRequests.shift(); if (request) win.webContents.send(IPC.DEEP_LINK_OPEN, request); @@ -601,7 +627,12 @@ function sendOpenRequestToRenderer( options: { stealFocus?: boolean } = {}, ) { const win = focusMainWindow(options); - if (!win || win.isDestroyed() || win.webContents.isLoading()) { + if ( + !win || + win.isDestroyed() || + win.webContents.isLoading() || + deepLinkReadyWebContentsId !== win.webContents.id + ) { pendingOpenRequests.push(request); return; } @@ -691,6 +722,38 @@ async function handleDeepLink(url: string) { runId, }); } else if (targetApp && getInjectionTargetForAppId(targetApp)) { + const requestedPreview = parsed.searchParams.get("preview"); + if (parsed.searchParams.has("preview")) { + const access = AppStore.loadProtectedPreviewAccess(targetApp); + const previewUrl = authorizeProtectedPreviewLaunch( + requestedPreview, + access?.origin, + ); + if (!previewUrl) { + console.warn( + `[main] rejected protected preview launch for ${targetApp}: no exact-origin credential binding`, + ); + focusMainWindow(); + return; + } + const authentication = await ensureProtectedPreviewAuthentication( + targetApp, + previewUrl, + ); + if (authentication === "blocked") { + console.warn( + `[main] protected preview preflight blocked navigation for ${targetApp}`, + ); + focusMainWindow(); + return; + } + sendOpenRequestToRenderer({ + app: targetApp, + previewUrl, + path: parsed.searchParams.get("path") ?? undefined, + }); + return; + } sendOpenRequestToRenderer({ app: targetApp, path: buildAppOpenRoutePath(parsed), @@ -798,12 +861,16 @@ async function injectSessionAndReload( for (const { session: sess, origin, cookieName } of targets) { try { + const isHostedHttps = new URL(origin).protocol === "https:"; await sess.cookies.set({ url: origin, name: cookieName, value: token, httpOnly: true, path: "/", + ...(isHostedHttps + ? { secure: true, sameSite: "no_restriction" as const } + : {}), expirationDate: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }); } catch (err) { @@ -864,7 +931,7 @@ app.on("open-url", (event, url) => { if (app.isReady()) { handleDeepLink(url); } else { - pendingDeepLink = url; + pendingDeepLinks.push(url); } }); @@ -974,11 +1041,19 @@ function createWindow(): BrowserWindow { }); desktopDesignPreviewManager?.destroy(); desktopDesignPreviewManager = new DesktopDesignPreviewManager(win); + const shellWebContentsId = win.webContents.id; + const resetDeepLinkReadiness = () => { + if (deepLinkReadyWebContentsId === shellWebContentsId) { + deepLinkReadyWebContentsId = null; + } + }; // Avoid white flash — show window once content is ready win.once("ready-to-show", () => win.show()); + win.webContents.on("did-start-loading", resetDeepLinkReadiness); + win.webContents.on("render-process-gone", resetDeepLinkReadiness); + win.webContents.on("destroyed", resetDeepLinkReadiness); win.webContents.on("did-finish-load", () => { - flushPendingOpenRequests(win); flushPendingDesktopShortcutActivations(win); }); @@ -992,6 +1067,7 @@ function createWindow(): BrowserWindow { mainWindow = win; win.on("closed", () => { + resetDeepLinkReadiness(); desktopDesignPreviewManager?.destroy(); desktopDesignPreviewManager = null; if (mainWindow === win) mainWindow = null; @@ -1150,6 +1226,12 @@ function scheduleDesktopShortcutActivationRetry(requestId: string) { }, delay); } +ipcMain.on(IPC.DEEP_LINK_READY, (event: IpcMainEvent) => { + if (!mainWindow || event.sender.id !== mainWindow.webContents.id) return; + deepLinkReadyWebContentsId = event.sender.id; + flushPendingOpenRequests(mainWindow); +}); + ipcMain.on(IPC.SET_ACTIVE_APP, (_event: IpcMainEvent, appId: string) => { activeAppId = appId; if (appId !== "design") desktopDesignPreviewManager?.clearOwner(); @@ -8482,6 +8564,490 @@ function installWebviewReloadGuard(contents: Electron.WebContents) { }); } +async function injectProtectedPreviewSession(input: { + token: string; + appId: string; + origin: string; + accessSecret: string; + accessEpoch: number; + session: Electron.Session; +}): Promise { + const cookieNames = new Set([getCookieNameForApp(input.appId), "an_session"]); + const removeInjectedCookies = async () => { + await Promise.allSettled( + [...cookieNames].map((cookieName) => + input.session.cookies.remove(input.origin, cookieName), + ), + ); + }; + + try { + for (const cookieName of cookieNames) { + await input.session.cookies.set({ + url: input.origin, + name: cookieName, + value: input.token, + httpOnly: true, + path: "/", + secure: true, + sameSite: "no_restriction", + expirationDate: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + }); + } + + const currentAccess = AppStore.loadProtectedPreviewAccess(input.appId); + const currentTarget = getInjectionTargetForAppId(input.appId); + if ( + currentAccess?.kind !== "shareable-link" || + currentAccess.origin !== input.origin || + currentAccess.secret !== input.accessSecret || + AppStore.getProtectedPreviewAccessEpoch(input.appId) !== + input.accessEpoch || + currentTarget?.session !== input.session + ) { + await removeInjectedCookies(); + return false; + } + + reloadWebviewsForTarget({ + ...currentTarget, + appId: input.appId, + origin: input.origin, + session: input.session, + }); + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + return true; + } catch { + await removeInjectedCookies(); + return false; + } +} + +async function pollProtectedPreviewOAuthRelay(input: { + appId: string; + origin: string; + accessSecret: string; + accessEpoch: number; + session: Electron.Session; + exchangeUrl: string; + flowId: string; +}) { + const deadline = Date.now() + PROTECTED_PREVIEW_OAUTH_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + const response = await fetch(input.exchangeUrl, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(5_000), + }); + const body = (await response.json().catch(() => null)) as { + token?: unknown; + error?: unknown; + } | null; + if (response.ok && typeof body?.token === "string" && body.token) { + const currentAccess = AppStore.loadProtectedPreviewAccess(input.appId); + const currentTarget = getInjectionTargetForAppId(input.appId); + if ( + currentAccess?.kind !== "shareable-link" || + currentAccess.origin !== input.origin || + currentAccess.secret !== input.accessSecret || + AppStore.getProtectedPreviewAccessEpoch(input.appId) !== + input.accessEpoch || + currentTarget?.session !== input.session + ) { + console.warn( + `[main] discarded stale protected preview OAuth completion for ${input.appId}`, + ); + return; + } + const injected = await injectProtectedPreviewSession({ + token: body.token, + appId: input.appId, + origin: input.origin, + accessSecret: input.accessSecret, + accessEpoch: input.accessEpoch, + session: input.session, + }); + if (!injected) { + console.warn( + `[main] discarded stale protected preview OAuth injection for ${input.appId}`, + ); + } + return; + } + if (body?.error) { + console.warn( + `[main] protected preview OAuth failed for ${input.appId}:`, + body.error, + ); + return; + } + } catch { + // The local app server may still be waking up. Keep the bounded poll + // alive; the preview remains untouched if the server never appears. + } + await new Promise((resolve) => + setTimeout(resolve, PROTECTED_PREVIEW_OAUTH_POLL_MS), + ); + } + console.warn( + `[main] protected preview OAuth timed out for ${input.appId} (flow ${input.flowId.slice(-10)})`, + ); +} + +async function runProtectedPreviewOAuthRelay(input: { + appId: string; + origin: string; + accessSecret: string; + accessEpoch: number; + session: Electron.Session; + starterUrl: string; + exchangeUrl: string; + flowId: string; + exchangeOrigin: string; +}) { + let unregister: (() => void) | null = null; + try { + unregister = await protectedPreviewOAuthDoorway.register( + input.flowId, + input.exchangeOrigin, + ); + openExternalUrl(input.starterUrl); + await pollProtectedPreviewOAuthRelay(input); + } catch (error) { + console.warn( + `[main] could not start protected preview OAuth for ${input.appId}:`, + error instanceof Error ? error.message : String(error), + ); + } finally { + unregister?.(); + if (protectedPreviewOAuthFlows.get(input.appId)?.flowId === input.flowId) { + protectedPreviewOAuthFlows.delete(input.appId); + } + } +} + +function openProtectedPreviewOAuthFromWebview( + url: string, + sourceContents: Electron.WebContents, +): boolean { + const appConfig = findAppForSourceUrl(sourceContents.getURL()); + if (!appConfig) return false; + const access = AppStore.loadProtectedPreviewAccess(appConfig.id); + const exchangeOrigin = appConfig.devPort + ? `http://127.0.0.1:${appConfig.devPort}` + : null; + if (!access || !exchangeOrigin) return false; + + const relay = resolveProtectedPreviewOAuthRelay({ + requestUrl: url, + configuredOrigin: access.origin, + doorwayOrigin: protectedPreviewOAuthDoorway.origin, + exchangeOrigin, + }); + if (!relay) return false; + const accessEpoch = AppStore.getProtectedPreviewAccessEpoch(appConfig.id); + const activeFlow = protectedPreviewOAuthFlows.get(appConfig.id); + if ( + activeFlow?.origin === access.origin && + activeFlow.accessEpoch === accessEpoch + ) { + return true; + } + protectedPreviewOAuthFlows.set(appConfig.id, { + flowId: relay.flowId, + origin: access.origin, + accessEpoch, + }); + + void runProtectedPreviewOAuthRelay({ + appId: appConfig.id, + origin: access.origin, + accessSecret: access.secret, + accessEpoch, + session: sourceContents.session, + starterUrl: relay.starterUrl, + exchangeUrl: relay.exchangeUrl, + flowId: relay.flowId, + exchangeOrigin, + }); + return true; +} + +async function ensureProtectedPreviewAuthentication( + appId: string, + previewUrl: string, +): Promise<"authenticated" | "needs-auth" | "blocked"> { + const access = AppStore.loadProtectedPreviewAccess(appId); + const accessEpoch = AppStore.getProtectedPreviewAccessEpoch(appId); + const target = getInjectionTargetForAppId(appId); + if (!access || access.origin !== previewUrl || !target?.session) { + return "blocked"; + } + + type SessionProbe = { + status: number; + state: "authenticated" | "not-authenticated" | "inconclusive"; + }; + let sessionProbe: SessionProbe | null = null; + const hasCurrentAccessBinding = () => { + const currentAccess = AppStore.loadProtectedPreviewAccess(appId); + const currentTarget = getInjectionTargetForAppId(appId); + return ( + currentAccess?.kind === "shareable-link" && + currentAccess.origin === access.origin && + currentAccess.secret === access.secret && + AppStore.getProtectedPreviewAccessEpoch(appId) === accessEpoch && + currentTarget?.session === target.session + ); + }; + + if (access.kind === "shareable-link") { + const removeVercelAccessCookies = async () => { + const cookies = await target.session!.cookies.get({ + url: previewUrl, + name: "_vercel_jwt", + }); + await Promise.all( + cookies.map((cookie) => + target.session!.cookies.remove( + new URL(cookie.path || "/", previewUrl).toString(), + cookie.name, + ), + ), + ); + }; + const bootstrapUrl = new URL("/", previewUrl); + bootstrapUrl.searchParams.set("_vercel_share", access.secret); + const bootstrapWindow = new BrowserWindow({ + show: false, + webPreferences: { + session: target.session, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + bootstrapWindow.webContents.setWindowOpenHandler(() => ({ + action: "deny", + })); + try { + await removeVercelAccessCookies(); + await bootstrapWindow.loadURL(bootstrapUrl.toString()); + const finalUrl = new URL(bootstrapWindow.webContents.getURL()); + const installedCookies = await target.session.cookies.get({ + url: previewUrl, + name: "_vercel_jwt", + }); + if ( + finalUrl.origin !== previewUrl || + finalUrl.searchParams.has("_vercel_share") || + installedCookies.length === 0 || + !hasCurrentAccessBinding() + ) { + await removeVercelAccessCookies(); + console.warn( + `[main] shareable-link bootstrap did not authorize ${appId}`, + ); + return "blocked"; + } + await Promise.all( + installedCookies.map((cookie) => + target.session!.cookies.set({ + url: previewUrl, + name: cookie.name, + value: cookie.value, + httpOnly: cookie.httpOnly, + path: cookie.path || "/", + secure: true, + sameSite: "no_restriction", + expirationDate: + cookie.expirationDate ?? + Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }), + ), + ); + if ( + !hasCurrentAccessBinding() || + bootstrapWindow.webContents.isDestroyed() || + new URL(bootstrapWindow.webContents.getURL()).origin !== previewUrl + ) { + await removeVercelAccessCookies(); + console.warn( + `[main] discarded stale shareable-link bootstrap for ${appId}`, + ); + return "blocked"; + } + sessionProbe = + (await bootstrapWindow.webContents.executeJavaScriptInIsolatedWorld( + 999, + [ + { + code: `fetch("/_agent-native/auth/session", { + credentials: "include", + headers: { Accept: "application/json" }, + }).then(async (response) => { + const body = await response.json().catch(() => null); + const state = response.ok && + body && + typeof body.email === "string" && + typeof body.token === "string" + ? "authenticated" + : body && body.error === "Not authenticated" + ? "not-authenticated" + : "inconclusive"; + return { status: response.status, state }; + })`, + }, + ], + )) as SessionProbe; + if ( + !hasCurrentAccessBinding() || + bootstrapWindow.webContents.isDestroyed() || + new URL(bootstrapWindow.webContents.getURL()).origin !== previewUrl + ) { + await removeVercelAccessCookies(); + console.warn( + `[main] discarded stale protected preview session probe for ${appId}`, + ); + return "blocked"; + } + reloadWebviewsForTarget({ ...target, appId, origin: previewUrl }); + } catch (error) { + await removeVercelAccessCookies().catch(() => undefined); + console.warn( + `[main] shareable-link bootstrap failed for ${appId}:`, + error instanceof Error ? error.message : String(error), + ); + return "blocked"; + } finally { + bootstrapWindow.destroy(); + } + } + + try { + const cookies = await target.session.cookies.get({ url: previewUrl }); + const sessionCookies = cookies.filter( + (cookie) => + cookie.name === "an_session" || + cookie.name === "an_session_workspace" || + cookie.name.startsWith("an_session_"), + ); + if (sessionCookies.length > 0) { + const removeSessionCookies = async () => { + await Promise.allSettled( + sessionCookies.map((cookie) => + target.session!.cookies.remove( + new URL(cookie.path || "/", previewUrl).toString(), + cookie.name, + ), + ), + ); + }; + if (!hasCurrentAccessBinding()) { + await removeSessionCookies(); + return "blocked"; + } + if (sessionProbe?.state === "authenticated") { + try { + await Promise.all( + sessionCookies.map((cookie) => + target.session!.cookies.set({ + url: previewUrl, + name: cookie.name, + value: cookie.value, + httpOnly: true, + path: "/", + secure: true, + sameSite: "no_restriction", + expirationDate: + cookie.expirationDate ?? + Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + }), + ), + ); + } catch { + await removeSessionCookies(); + return "blocked"; + } + if (!hasCurrentAccessBinding()) { + await removeSessionCookies(); + return "blocked"; + } + reloadWebviewsForTarget({ ...target, appId, origin: previewUrl }); + return "authenticated"; + } + + if (sessionProbe?.state === "not-authenticated") { + await removeSessionCookies(); + console.warn( + `[main] discarded an explicitly unauthenticated protected preview session for ${appId}`, + ); + } else { + console.warn( + `[main] protected preview session probe was inconclusive for ${appId} (HTTP ${sessionProbe?.status ?? "unknown"}); preserving cookies`, + ); + return "blocked"; + } + } + } catch (error) { + console.warn( + `[main] could not inspect protected preview session for ${appId}:`, + error instanceof Error ? error.message : String(error), + ); + return "blocked"; + } + + const appConfig = AppStore.loadApps().find((app) => app.id === appId); + const exchangeOrigin = appConfig?.devPort + ? `http://127.0.0.1:${appConfig.devPort}` + : null; + if (!hasCurrentAccessBinding()) return "blocked"; + if (!exchangeOrigin) return "needs-auth"; + const activeFlow = protectedPreviewOAuthFlows.get(appId); + if ( + activeFlow?.origin === access.origin && + activeFlow.accessEpoch === accessEpoch + ) { + return "needs-auth"; + } + + const requestUrl = new URL("/_agent-native/google/auth-url", previewUrl); + requestUrl.searchParams.set("desktop", "1"); + requestUrl.searchParams.set("redirect", "1"); + requestUrl.searchParams.set("flow_id", randomUUID()); + requestUrl.searchParams.set("return", "/"); + const relay = resolveProtectedPreviewOAuthRelay({ + requestUrl: requestUrl.toString(), + configuredOrigin: access.origin, + doorwayOrigin: protectedPreviewOAuthDoorway.origin, + exchangeOrigin, + }); + if (!relay) return "blocked"; + + protectedPreviewOAuthFlows.set(appId, { + flowId: relay.flowId, + origin: access.origin, + accessEpoch, + }); + void runProtectedPreviewOAuthRelay({ + appId, + origin: access.origin, + accessSecret: access.secret, + accessEpoch, + session: target.session, + starterUrl: relay.starterUrl, + exchangeUrl: relay.exchangeUrl, + flowId: relay.flowId, + exchangeOrigin, + }); + return "needs-auth"; +} + function openOAuthFromWebviewNavigation( url: string, sourceContents: Electron.WebContents, @@ -8564,6 +9130,9 @@ function handleWindowOpenForContents( if (handleDesktopProtocolUrl(url)) { return { action: "deny" as const }; } + if (openProtectedPreviewOAuthFromWebview(url, contents)) { + return { action: "deny" as const }; + } try { const parsed = new URL(url); @@ -8607,6 +9176,10 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { event.preventDefault(); return; } + if (openProtectedPreviewOAuthFromWebview(url, contents)) { + event.preventDefault(); + return; + } if (openOAuthFromWebviewNavigation(url, contents)) { event.preventDefault(); return; @@ -8953,9 +9526,8 @@ function configurePermissionHandlers( app.whenReady().then(async () => { await initializeDesktopComputerMcpBridge(); // Process any deep link that arrived before the app was ready - if (pendingDeepLink) { - handleDeepLink(pendingDeepLink); - pendingDeepLink = null; + for (const deepLink of pendingDeepLinks.splice(0)) { + await handleDeepLink(deepLink); } // Webviews now run in per-app persisted partitions (persist:app-), so @@ -8989,7 +9561,12 @@ app.whenReady().then(async () => { // Each partition is bound to a specific app, so route to that app's port // rather than falling back to a hardcoded mail/calendar preference. sess.webRequest.onBeforeRequest( - { urls: [`http://localhost:${FRAME_PORT}/api/google/*`] }, + { + urls: [ + `http://localhost:${FRAME_PORT}/api/google/*`, + `http://localhost:${FRAME_PORT}/_agent-native/google/*`, + ], + }, (details, callback) => { let apps: AppConfig[] = []; try { @@ -9004,12 +9581,15 @@ app.whenReady().then(async () => { apps.find((a) => a.id === "mail") || apps.find((a) => a.id === "calendar"); if (app) { - const gatewayAppUrl = resolveDesktopTemplateGatewayUrl(app); - const appUrl = details.url.replace( - `http://localhost:${FRAME_PORT}`, - gatewayAppUrl || `http://localhost:${app.devPort}`, - ); - callback({ redirectURL: appUrl }); + const baseUrl = resolveAppBaseUrl(app); + const appUrl = baseUrl + ? resolveFrameOAuthCallbackTarget({ + appBaseUrl: baseUrl, + callbackUrl: details.url, + framePort: FRAME_PORT, + }) + : null; + callback(appUrl ? { redirectURL: appUrl } : {}); } else { callback({}); } diff --git a/packages/desktop-app/src/main/ipc/apps.ts b/packages/desktop-app/src/main/ipc/apps.ts index 8b4304ae0c..32d75c49a6 100644 --- a/packages/desktop-app/src/main/ipc/apps.ts +++ b/packages/desktop-app/src/main/ipc/apps.ts @@ -6,8 +6,9 @@ import { type DesktopCreateAppRequest, type DesktopCreateAppResult, type LocalAppFolderSelectResult, + type ProtectedPreviewAccessStatus, } from "@shared/ipc-channels"; -import { ipcMain, type IpcMainInvokeEvent } from "electron"; +import { ipcMain, session, type IpcMainInvokeEvent } from "electron"; import * as AppStore from "../app-store"; @@ -27,6 +28,28 @@ export interface AppsIpcDeps { ) => Promise; } +async function clearProtectedPreviewCookies( + appId: string, + origin: string, +): Promise { + const previewSession = session.fromPartition(`persist:app-${appId}`); + const cookies = await previewSession.cookies.get({ url: origin }); + await Promise.all( + cookies.map((cookie) => + previewSession.cookies.remove( + new URL(cookie.path || "/", origin).toString(), + cookie.name, + ), + ), + ); +} + +async function clearAppBrowserCookies(appId: string): Promise { + await session + .fromPartition(`persist:app-${appId}`) + .clearStorageData({ storages: ["cookies"] }); +} + /** Registers the app-config (sidebar app list) CRUD and creation IPC handlers. */ export function registerAppsIpc(deps: AppsIpcDeps): void { const { @@ -44,6 +67,69 @@ export function registerAppsIpc(deps: AppsIpcDeps): void { return AppStore.loadApps(); }); + ipcMain.handle( + IPC.PROTECTED_PREVIEW_GET, + (_event: IpcMainInvokeEvent, appId: string): ProtectedPreviewAccessStatus => + AppStore.getProtectedPreviewAccessStatus(appId), + ); + + ipcMain.handle( + IPC.PROTECTED_PREVIEW_SAVE, + ( + _event: IpcMainInvokeEvent, + appId: string, + origin: string, + secret: string, + ): ProtectedPreviewAccessStatus => + AppStore.saveProtectedPreviewAccess(appId, origin, secret), + ); + + ipcMain.handle( + IPC.PROTECTED_PREVIEW_CLEAR, + async ( + _event: IpcMainInvokeEvent, + appId: string, + ): Promise => { + const access = AppStore.loadProtectedPreviewAccess(appId); + const app = AppStore.loadApps().find( + (candidate) => candidate.id === appId, + ); + const status = AppStore.clearProtectedPreviewAccess(appId); + let cleanupError: string | undefined; + + if (access) { + try { + await clearProtectedPreviewCookies(appId, access.origin); + } catch { + cleanupError = + "Preview access was cleared, but its persisted browser session could not be removed."; + } + } + + if (access && app?.devUrl && app.devPort) { + try { + if (new URL(app.devUrl).origin === access.origin) { + AppStore.updateApp(appId, { + devUrl: `http://localhost:${app.devPort}`, + mode: "dev", + }); + } + } catch { + // Invalid legacy dev URLs are left untouched. + } + } + + const restoreApp = AppStore.loadApps().find( + (candidate) => candidate.id === appId, + ); + return { + ...status, + ...(restoreApp ? { restoreApp } : {}), + ...(cleanupError ? { error: cleanupError } : {}), + }; + }, + ); + ipcMain.handle( IPC.APPS_ADD, (_event: IpcMainInvokeEvent, app: AppConfig): AppConfig[] => { @@ -55,8 +141,10 @@ export function registerAppsIpc(deps: AppsIpcDeps): void { ipcMain.handle( IPC.APPS_REMOVE, - (_event: IpcMainInvokeEvent, id: string): AppConfig[] => { + async (_event: IpcMainInvokeEvent, id: string): Promise => { stopManagedDesktopApp(id); + AppStore.invalidateProtectedPreviewAccess(id); + await clearAppBrowserCookies(id); const apps = AppStore.removeApp(id); refreshDesktopShortcutBindings(); return apps; @@ -85,10 +173,15 @@ export function registerAppsIpc(deps: AppsIpcDeps): void { ): AppConfig[] => AppStore.reorderApp(id, direction), ); - ipcMain.handle(IPC.APPS_RESET, (): AppConfig[] => { + ipcMain.handle(IPC.APPS_RESET, async (): Promise => { + const configuredAppIds = AppStore.loadApps().map((app) => app.id); + for (const appId of configuredAppIds) { + AppStore.invalidateProtectedPreviewAccess(appId); + } for (const appId of getManagedDesktopAppIds()) { stopManagedDesktopApp(appId); } + await Promise.all(configuredAppIds.map(clearAppBrowserCookies)); const apps = AppStore.resetToDefaults(); refreshDesktopShortcutBindings(); return apps; diff --git a/packages/desktop-app/src/main/protected-preview-oauth-doorway.test.ts b/packages/desktop-app/src/main/protected-preview-oauth-doorway.test.ts new file mode 100644 index 0000000000..92f9abb777 --- /dev/null +++ b/packages/desktop-app/src/main/protected-preview-oauth-doorway.test.ts @@ -0,0 +1,181 @@ +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, +} from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ProtectedPreviewOAuthDoorway } from "./protected-preview-oauth-doorway"; + +const servers: http.Server[] = []; +const doorways: ProtectedPreviewOAuthDoorway[] = []; + +afterEach(async () => { + await Promise.all(doorways.splice(0).map((doorway) => doorway.close())); + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve) => server.close(() => resolve())), + ), + ); +}); + +async function listen( + handler: (request: IncomingMessage, response: http.ServerResponse) => void, +): Promise<{ server: http.Server; origin: string }> { + const server = http.createServer(handler); + servers.push(server); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const port = (server.address() as AddressInfo).port; + return { server, origin: `http://127.0.0.1:${port}` }; +} + +async function getRaw( + url: string, + headers: OutgoingHttpHeaders = {}, +): Promise<{ status: number; headers: IncomingHttpHeaders }> { + return new Promise((resolve, reject) => { + const request = http.get(url, { headers }, (response) => { + response.resume(); + response.once("end", () => { + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + }); + }); + }); + request.once("error", reject); + }); +} + +function callbackState(flowId: string): string { + const payload = Buffer.from(JSON.stringify({ f: flowId })).toString( + "base64url", + ); + return `${payload}.signature-not-trusted-for-routing`; +} + +describe("ProtectedPreviewOAuthDoorway", () => { + it("proxies only a registered starter and callback to its loopback app", async () => { + const requests: Array<{ + url: string; + headers: IncomingMessage["headers"]; + }> = []; + const upstream = await listen((request, response) => { + requests.push({ + url: request.url ?? "", + headers: request.headers, + }); + response.writeHead(302, { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + location: "https://accounts.google.com/", + "referrer-policy": "no-referrer", + "set-cookie": "doorway=must-not-be-set; Path=/; HttpOnly", + "www-authenticate": "Bearer example-upstream-challenge", + "x-unlisted-response": "must-not-cross-doorway", + }); + response.end(); + }); + const doorway = new ProtectedPreviewOAuthDoorway({ port: 0 }); + doorways.push(doorway); + const flowId = "flow-12345"; + const unregister = await doorway.register(flowId, upstream.origin); + + const starter = await getRaw( + `${doorway.origin}/_agent-native/google/auth-url?desktop=1&flow_id=${flowId}&redirect=1`, + { + authorization: "Bearer example-authorization-not-a-token", + cookie: "session=example-cookie-not-a-session", + "proxy-authorization": "Basic example-proxy-credentials", + "user-agent": "AgentNativeDesktop/example", + "x-unlisted-example": "must-not-cross-doorway", + }, + ); + expect(starter.status).toBe(302); + expect(requests[0]?.url).toBe( + `/_agent-native/google/auth-url?desktop=1&flow_id=${flowId}&redirect=1`, + ); + expect(requests[0]?.headers).toMatchObject({ + host: new URL(upstream.origin).host, + "user-agent": "AgentNativeDesktop/example", + "x-forwarded-host": new URL(doorway.origin).host, + "x-forwarded-proto": "http", + }); + expect(requests[0]?.headers).not.toHaveProperty("authorization"); + expect(requests[0]?.headers).not.toHaveProperty("cookie"); + expect(requests[0]?.headers).not.toHaveProperty("proxy-authorization"); + expect(requests[0]?.headers).not.toHaveProperty("x-unlisted-example"); + expect(starter.headers).toMatchObject({ + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + location: "https://accounts.google.com/", + "referrer-policy": "no-referrer", + }); + expect(starter.headers).not.toHaveProperty("set-cookie"); + expect(starter.headers).not.toHaveProperty("www-authenticate"); + expect(starter.headers).not.toHaveProperty("x-unlisted-response"); + + const callback = await fetch( + `${doorway.origin}/_agent-native/google/callback?code=example&state=${encodeURIComponent(callbackState(flowId))}`, + { + redirect: "manual", + headers: { + authorization: "Bearer example-callback-not-a-token", + cookie: "session=example-callback-not-a-session", + "user-agent": "AgentNativeDesktop/example", + }, + }, + ); + expect(callback.status).toBe(302); + expect(requests[1]?.url).toContain( + "/_agent-native/google/callback?code=example&state=", + ); + expect(requests[1]?.headers["user-agent"]).toBe( + "AgentNativeDesktop/example", + ); + expect(requests[1]?.headers).not.toHaveProperty("authorization"); + expect(requests[1]?.headers).not.toHaveProperty("cookie"); + + unregister(); + }); + + it("rejects unrelated routes, unknown flows, and non-loopback targets", async () => { + const doorway = new ProtectedPreviewOAuthDoorway({ port: 0 }); + doorways.push(doorway); + const upstream = await listen((_request, response) => response.end("ok")); + await doorway.register("known-flow", upstream.origin); + + expect((await fetch(`${doorway.origin}/collect`)).status).toBe(404); + expect( + ( + await fetch( + `${doorway.origin}/_agent-native/google/auth-url?desktop=1&flow_id=unknown-flow&redirect=1`, + ) + ).status, + ).toBe(404); + await expect( + doorway.register("other-flow", "https://candidate.example.test"), + ).rejects.toThrow("loopback HTTP origins"); + }); + + it("fails closed without interrupting a process that already owns the doorway port", async () => { + const existing = await listen((_request, response) => + response.end("still here"), + ); + const port = Number(new URL(existing.origin).port); + const doorway = new ProtectedPreviewOAuthDoorway({ port }); + doorways.push(doorway); + + await expect( + doorway.register("occupied-flow", "http://127.0.0.1:8083"), + ).rejects.toThrow("already in use"); + expect(await (await fetch(existing.origin)).text()).toBe("still here"); + }); +}); diff --git a/packages/desktop-app/src/main/protected-preview-oauth-doorway.ts b/packages/desktop-app/src/main/protected-preview-oauth-doorway.ts new file mode 100644 index 0000000000..b1744c5027 --- /dev/null +++ b/packages/desktop-app/src/main/protected-preview-oauth-doorway.ts @@ -0,0 +1,257 @@ +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; + +const GOOGLE_STARTER_PATH = "/_agent-native/google/auth-url"; +const GOOGLE_CALLBACK_PATH = "/_agent-native/google/callback"; +const FLOW_TTL_MS = 5 * 60 * 1000; +const FORWARDED_RESPONSE_HEADERS = [ + "cache-control", + "content-type", + "expires", + "location", + "pragma", + "referrer-policy", +] as const; + +interface DoorwayRegistration { + targetOrigin: string; + expiresAt: number; +} + +function isSafeFlowId(value: string | null): value is string { + return Boolean(value && /^[A-Za-z0-9_-]{8,128}$/.test(value)); +} + +function normalizeLoopbackOrigin(value: string): string | null { + try { + const url = new URL(value); + const loopback = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if ( + url.protocol !== "http:" || + !loopback || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + return null; + } + return url.origin; + } catch { + return null; + } +} + +function flowIdFromState(state: string | null): string | null { + if (!state || state.length > 16_384) return null; + try { + const separator = state.lastIndexOf("."); + if (separator <= 0) return null; + const payload = JSON.parse( + Buffer.from(state.slice(0, separator), "base64url").toString("utf8"), + ) as { f?: unknown }; + return typeof payload.f === "string" && isSafeFlowId(payload.f) + ? payload.f + : null; + } catch { + return null; + } +} + +function requestFlowId(url: URL): string | null { + if (url.pathname === GOOGLE_STARTER_PATH) { + const flowId = url.searchParams.get("flow_id"); + return isSafeFlowId(flowId) ? flowId : null; + } + if (url.pathname === GOOGLE_CALLBACK_PATH) { + return flowIdFromState(url.searchParams.get("state")); + } + return null; +} + +function proxyHeaders( + headers: IncomingHttpHeaders, + target: URL, + publicOrigin: string, +): OutgoingHttpHeaders { + return { + // OAuth uses this to distinguish desktop and mobile callback behavior. + ...(headers["user-agent"] ? { "user-agent": headers["user-agent"] } : {}), + host: target.host, + "x-forwarded-host": new URL(publicOrigin).host, + "x-forwarded-proto": "http", + }; +} + +function proxyResponseHeaders( + headers: IncomingHttpHeaders, +): OutgoingHttpHeaders { + const forwarded: Record = {}; + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = headers[name]; + if (value !== undefined) forwarded[name] = value; + } + return forwarded; +} + +export class ProtectedPreviewOAuthDoorway { + private server: Server | null = null; + private boundPort: number | null = null; + private readonly registrations = new Map(); + + constructor( + private readonly options: { + port?: number; + host?: string; + publicOrigin?: string; + } = {}, + ) {} + + get origin(): string { + return ( + this.options.publicOrigin ?? + `http://localhost:${this.boundPort ?? this.options.port ?? 8080}` + ); + } + + async register(flowId: string, targetOrigin: string): Promise<() => void> { + if (!isSafeFlowId(flowId)) throw new Error("Invalid OAuth flow id."); + const normalizedTarget = normalizeLoopbackOrigin(targetOrigin); + if (!normalizedTarget) { + throw new Error("OAuth doorway targets must be loopback HTTP origins."); + } + await this.ensureListening(); + this.prune(); + this.registrations.set(flowId, { + targetOrigin: normalizedTarget, + expiresAt: Date.now() + FLOW_TTL_MS, + }); + return () => { + this.registrations.delete(flowId); + this.closeIfIdle(); + }; + } + + async close(): Promise { + this.registrations.clear(); + const server = this.server; + this.server = null; + this.boundPort = null; + if (!server) return; + await new Promise((resolve) => server.close(() => resolve())); + } + + private prune(): void { + const now = Date.now(); + for (const [flowId, registration] of this.registrations) { + if (registration.expiresAt <= now) this.registrations.delete(flowId); + } + } + + private closeIfIdle(): void { + this.prune(); + if (this.registrations.size !== 0 || !this.server) return; + const server = this.server; + this.server = null; + this.boundPort = null; + server.close(() => {}); + } + + private async ensureListening(): Promise { + if (this.server?.listening) return; + const server = http.createServer((request, response) => { + this.handleRequest(request, response); + }); + this.server = server; + await new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + server.off("listening", onListening); + if (this.server === server) this.server = null; + this.boundPort = null; + reject( + error.code === "EADDRINUSE" + ? new Error( + `OAuth doorway ${this.origin} is already in use. Leave the current process alone and retry when the port is free.`, + ) + : error, + ); + }; + const onListening = () => { + server.off("error", onError); + this.boundPort = (server.address() as AddressInfo).port; + server.unref(); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen( + this.options.port ?? 8080, + this.options.host ?? "127.0.0.1", + ); + }); + } + + private handleRequest( + request: IncomingMessage, + response: ServerResponse, + ): void { + if (request.method !== "GET") { + response.writeHead(405, { "content-type": "text/plain" }); + response.end("Method not allowed"); + return; + } + let requestUrl: URL; + try { + requestUrl = new URL(request.url ?? "/", this.origin); + } catch { + response.writeHead(400, { "content-type": "text/plain" }); + response.end("Bad request"); + return; + } + const flowId = requestFlowId(requestUrl); + this.prune(); + const registration = flowId ? this.registrations.get(flowId) : null; + if (!registration) { + response.writeHead(404, { "content-type": "text/plain" }); + response.end("OAuth flow not found"); + return; + } + + const target = new URL( + `${requestUrl.pathname}${requestUrl.search}`, + registration.targetOrigin, + ); + const proxyRequest = http.request( + target, + { + method: "GET", + headers: proxyHeaders(request.headers, target, this.origin), + }, + (proxyResponse) => { + response.writeHead( + proxyResponse.statusCode ?? 502, + proxyResponseHeaders(proxyResponse.headers), + ); + proxyResponse.pipe(response); + }, + ); + proxyRequest.once("error", () => { + if (response.headersSent) { + response.end(); + return; + } + response.writeHead(502, { "content-type": "text/plain" }); + response.end("OAuth app is not available"); + }); + request.once("aborted", () => proxyRequest.destroy()); + proxyRequest.end(); + } +} diff --git a/packages/desktop-app/src/preload/index.ts b/packages/desktop-app/src/preload/index.ts index 4764327afe..8b0faf52b2 100644 --- a/packages/desktop-app/src/preload/index.ts +++ b/packages/desktop-app/src/preload/index.ts @@ -47,6 +47,7 @@ import { type DesktopShortcutUpsertRequest, type InterAppMessage, type LocalAppFolderSelectResult, + type ProtectedPreviewAccessStatus, type UpdateStatus, } from "@shared/ipc-channels"; import { isDesktopSentryConfigured } from "@shared/sentry-config"; @@ -188,6 +189,20 @@ const electronAPI = { }, }, + /** Exact-origin access for protected hosted previews. */ + protectedPreview: { + get: (appId: string): Promise => + ipcRenderer.invoke(IPC.PROTECTED_PREVIEW_GET, appId), + save: ( + appId: string, + origin: string, + secret: string, + ): Promise => + ipcRenderer.invoke(IPC.PROTECTED_PREVIEW_SAVE, appId, origin, secret), + clear: (appId: string): Promise => + ipcRenderer.invoke(IPC.PROTECTED_PREVIEW_CLEAR, appId), + }, + /** Tell main process which app webview is currently active (for DevTools targeting) */ setActiveApp: (appId: string) => ipcRenderer.send(IPC.SET_ACTIVE_APP, appId), setActiveWebview: (target: ActiveWebviewTarget) => @@ -348,6 +363,7 @@ const electronAPI = { ipcRenderer.on(IPC.DEEP_LINK_OPEN, handler); return () => ipcRenderer.removeListener(IPC.DEEP_LINK_OPEN, handler); }, + readyForOpenRequests: () => ipcRenderer.send(IPC.DEEP_LINK_READY), }, /** Inter-app communication — relay messages between loaded apps */ diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 5cb6308511..63cd8563f7 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { type AppDefinition, type AppConfig, type FrameSettings, + normalizeProtectedPreviewOrigin, toAppDefinition, } from "@shared/app-registry"; import { @@ -742,10 +743,30 @@ export default function App() { useEffect(() => { if (!window.electronAPI?.codeAgents?.onOpenRequest) return; return window.electronAPI.codeAgents.onOpenRequest((request) => { - setPendingDesktopOpenRequest(request); + const previewUrl = normalizeProtectedPreviewOrigin(request.previewUrl); + if (!previewUrl || !request.app) { + setPendingDesktopOpenRequest(request); + return; + } + setApps((current) => + current.map((app) => + app.id === request.app + ? { ...app, devUrl: previewUrl, mode: "dev" } + : app, + ), + ); + setPendingDesktopOpenRequest({ + ...request, + previewUrl: undefined, + }); }); }, []); + useEffect(() => { + if (loading) return; + window.electronAPI?.codeAgents?.readyForOpenRequests?.(); + }, [loading]); + useEffect(() => { const shortcutApi = window.electronAPI?.shortcuts; if (!shortcutApi?.onActivate) return; diff --git a/packages/desktop-app/src/renderer/components/AppSettings.tsx b/packages/desktop-app/src/renderer/components/AppSettings.tsx index b12a77758b..0003286b9c 100644 --- a/packages/desktop-app/src/renderer/components/AppSettings.tsx +++ b/packages/desktop-app/src/renderer/components/AppSettings.tsx @@ -3,6 +3,7 @@ import { generateAppId, getDesktopTemplateGatewayAppUrl, isDefaultDesktopTemplateDevTarget, + normalizeProtectedPreviewOrigin, } from "@shared/app-registry"; import { formatDesktopShortcutAccelerator, @@ -13,7 +14,10 @@ import { type DesktopShortcutSettings, type DesktopShortcutUpsertRequest, } from "@shared/desktop-shortcuts"; -import type { UpdateStatus } from "@shared/ipc-channels"; +import type { + ProtectedPreviewAccessStatus, + UpdateStatus, +} from "@shared/ipc-channels"; import { IconX, IconPlus, @@ -1764,27 +1768,98 @@ export function AppEditForm({ const [devUrl, setDevUrl] = useState(app?.devUrl ?? ""); const [devCommand, setDevCommand] = useState(app?.devCommand ?? ""); const [description, setDescription] = useState(app?.description ?? ""); + const [previewSecret, setPreviewSecret] = useState(""); + const [previewStatus, setPreviewStatus] = + useState(null); + const [previewError, setPreviewError] = useState(""); + const [previewSaving, setPreviewSaving] = useState(false); - function handleSubmit(e: React.FormEvent) { + useEffect(() => { + if (!app?.id || !window.electronAPI?.protectedPreview) return; + window.electronAPI.protectedPreview + .get(app.id) + .then(setPreviewStatus) + .catch((err) => + setPreviewError(err instanceof Error ? err.message : String(err)), + ); + }, [app?.id]); + + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const trimmedUrl = url.trim(); const trimmedDevUrl = devUrl.trim(); if (!name.trim() || (!trimmedUrl && !trimmedDevUrl)) return; - onSave({ - id: app?.id ?? generateAppId(), - name: name.trim(), - icon: app?.icon ?? "Globe", - description: description.trim() || name.trim(), - url: trimmedUrl, - devPort: app?.devPort || inferPortFromUrl(trimmedDevUrl), - devUrl: trimmedDevUrl || undefined, - devCommand: devCommand.trim() || undefined, - localPath: app?.localPath, - isBuiltIn: app?.isBuiltIn ?? false, - enabled: app?.enabled ?? true, - mode: app?.mode ?? (trimmedUrl ? "prod" : "dev"), - }); + const id = app?.id ?? generateAppId(); + const previewOrigin = normalizeProtectedPreviewOrigin( + trimmedDevUrl || trimmedUrl, + ); + setPreviewError(""); + setPreviewSaving(true); + try { + if (previewSecret.trim()) { + if (!previewOrigin) { + setPreviewError( + "Protected preview access requires an exact HTTPS app URL.", + ); + return; + } + const status = await window.electronAPI?.protectedPreview?.save( + id, + previewOrigin, + previewSecret, + ); + if (!status?.configured) { + setPreviewStatus(status ?? null); + setPreviewError( + status?.error ?? "The protected preview credential was not saved.", + ); + return; + } + setPreviewStatus(status); + } else if ( + previewStatus?.configured && + previewStatus.origin !== previewOrigin + ) { + setPreviewError( + "This preview URL differs from the credential's exact origin. Re-enter the credential to bind the new candidate.", + ); + return; + } + + onSave({ + id, + name: name.trim(), + icon: app?.icon ?? "Globe", + description: description.trim() || name.trim(), + url: trimmedUrl, + devPort: app?.devPort || inferPortFromUrl(trimmedDevUrl), + devUrl: trimmedDevUrl || undefined, + devCommand: devCommand.trim() || undefined, + localPath: app?.localPath, + isBuiltIn: app?.isBuiltIn ?? false, + enabled: app?.enabled ?? true, + mode: app?.mode ?? (trimmedUrl ? "prod" : "dev"), + }); + } finally { + setPreviewSaving(false); + } + } + + async function clearPreviewAccess() { + if (!app?.id || !window.electronAPI?.protectedPreview) return; + setPreviewSaving(true); + setPreviewError(""); + try { + const status = await window.electronAPI.protectedPreview.clear(app.id); + setPreviewStatus(status); + setPreviewSecret(""); + if (status.restoreApp) onSave(status.restoreApp); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : String(err)); + } finally { + setPreviewSaving(false); + } } return ( @@ -1837,6 +1912,45 @@ export function AppEditForm({ /> + + + {previewStatus?.configured && ( + + )} + + {(previewError || previewStatus?.error) && ( +

+ + {previewError || previewStatus?.error} +

+ )} +