diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts index fa3b1ac1..1d1bd3b0 100644 --- a/apps/x/apps/main/src/browser/ipc.ts +++ b/apps/x/apps/main/src/browser/ipc.ts @@ -1,6 +1,6 @@ import { BrowserWindow } from 'electron'; import { ipc } from '@x/shared'; -import { browserViewManager, type BrowserState } from './view.js'; +import { browserViewManager, type BrowserState, type HttpAuthRequest } from './view.js'; type IPCChannels = ipc.IPCChannels; @@ -20,6 +20,7 @@ type BrowserHandlers = { 'browser:forward': InvokeHandler<'browser:forward'>; 'browser:reload': InvokeHandler<'browser:reload'>; 'browser:getState': InvokeHandler<'browser:getState'>; + 'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>; }; /** @@ -62,6 +63,9 @@ export const browserIpcHandlers: BrowserHandlers = { 'browser:getState': async () => { return browserViewManager.getState(); }, + 'browser:httpAuthResponse': async (_event, args) => { + return browserViewManager.respondToHttpAuth(args); + }, }; /** @@ -70,12 +74,26 @@ export const browserIpcHandlers: BrowserHandlers = { * window is created so the manager has a window to attach to. */ export function setupBrowserEventForwarding(): void { - browserViewManager.on('state-updated', (state: BrowserState) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('browser:didUpdateState', state); - } + // Only send to app windows, never to OAuth/SSO popup windows created by + // page window.open() — those render untrusted web content, and browsing + // state / auth-challenge metadata must not cross into them. + const broadcast = (channel: string, payload: unknown) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed() || !win.webContents) continue; + if (browserViewManager.isPopupWindow(win)) continue; + win.webContents.send(channel, payload); } + }; + + browserViewManager.on('state-updated', (state: BrowserState) => { + broadcast('browser:didUpdateState', state); + }); + + browserViewManager.on('http-auth-request', (request: HttpAuthRequest) => { + broadcast('browser:httpAuthRequest', request); + }); + + browserViewManager.on('http-auth-resolved', (requestId: string) => { + broadcast('browser:httpAuthResolved', { requestId }); }); } diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index 90b7d849..034aa06d 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -1,11 +1,12 @@ import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron'; +import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron'; import type { BrowserPageElement, BrowserPageSnapshot, BrowserState, BrowserTabState, + HttpAuthRequest, } from '@x/shared/dist/browser-control.js'; import { normalizeNavigationTarget } from './navigation.js'; import { @@ -20,7 +21,7 @@ import { type RawBrowserPageSnapshot, } from './page-scripts.js'; -export type { BrowserPageSnapshot, BrowserState, BrowserTabState }; +export type { BrowserPageSnapshot, BrowserState, BrowserTabState, HttpAuthRequest }; /** * Embedded browser pane implementation. @@ -36,13 +37,33 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState }; export const BROWSER_PARTITION = 'persist:rowboat-browser'; -// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers -// that sniff the UA looking for "real browser" shapes. -const SPOOF_UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; +// Spoof a real Chrome UA so OAuth servers don't reject the embedded browser. +// The Chrome major version is derived from the running Chromium at startup: +// pinning a fixed version goes stale as Electron upgrades, and Chromium keeps +// emitting Sec-CH-UA client hints with the *real* version — a UA/client-hint +// version mismatch is a classic bot-detection signal (Google sign-in, +// Cloudflare). Minor version is frozen at 0.0.0, exactly like real Chrome's +// reduced UA. The platform token matches the actual OS for the same reason. +function getChromeMajorVersion(): number { + const major = Number.parseInt(process.versions.chrome ?? '', 10); + return Number.isFinite(major) && major > 0 ? major : 130; +} + +function buildChromeUserAgent(): string { + const platformToken = + process.platform === 'darwin' + ? 'Macintosh; Intel Mac OS X 10_15_7' + : process.platform === 'win32' + ? 'Windows NT 10.0; Win64; x64' + : 'X11; Linux x86_64'; + return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${getChromeMajorVersion()}.0.0.0 Safari/537.36`; +} + +const SPOOF_UA = buildChromeUserAgent(); const HOME_URL = 'https://www.google.com'; const NAVIGATION_TIMEOUT_MS = 10000; +const HTTP_AUTH_TIMEOUT_MS = 120000; const POST_ACTION_IDLE_MS = 400; const POST_ACTION_MAX_ELEMENTS = 25; const POST_ACTION_MAX_TEXT_LENGTH = 4000; @@ -68,6 +89,13 @@ type CachedSnapshot = { elements: Array<{ index: number; selector: string }>; }; +type PendingHttpAuth = { + callback: (username?: string, password?: string) => void; + timer: NodeJS.Timeout; + // The webContents that raised the challenge, so its teardown can cancel it. + webContents: WebContents; +}; + const EMPTY_STATE: BrowserState = { activeTabId: null, tabs: [], @@ -109,6 +137,12 @@ export class BrowserViewManager extends EventEmitter { private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; private snapshotCache = new Map(); + private pendingHttpAuth = new Map(); + // Child windows created by page window.open() (OAuth/SSO popups). Tracked so + // they can be closed when the host window goes away — otherwise an orphaned + // popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks + // the app from reopening via the Dock (see main.ts 'activate' handler). + private popupWindows = new Set(); private cleanupWindowListeners: (() => void) | null = null; attach(window: BrowserWindow): void { @@ -137,6 +171,7 @@ export class BrowserViewManager extends EventEmitter { if (this.window !== window) return; const tabs = [...this.tabs.values()]; + const popups = [...this.popupWindows]; this.cleanupWindowListeners = null; this.window = null; this.browserSession = null; @@ -150,6 +185,14 @@ export class BrowserViewManager extends EventEmitter { this.attachedTabId = null; this.visible = false; this.snapshotCache.clear(); + for (const requestId of [...this.pendingHttpAuth.keys()]) { + this.finishHttpAuth(requestId); + } + // Close any OAuth/SSO popups so they don't outlive the app window. + for (const popup of popups) { + if (!popup.isDestroyed()) popup.close(); + } + this.popupWindows.clear(); }; hostWebContents.on('did-start-loading', handleDidStartLoading); @@ -171,6 +214,36 @@ export class BrowserViewManager extends EventEmitter { if (this.browserSession) return this.browserSession; const browserSession = session.fromPartition(BROWSER_PARTITION); browserSession.setUserAgent(SPOOF_UA); + + // Electron's Sec-CH-UA client hints only carry the "Chromium" brand; + // real Chrome also sends "Google Chrome". Some sign-in flows (notably + // Google's) distinguish the two, so rewrite the brand list to match what + // Chrome sends. Both the low-entropy header (`sec-ch-ua`, major versions) + // and the high-entropy one (`sec-ch-ua-full-version-list`, requested via + // Accept-CH and carrying full versions) must be rewritten together — a + // header that claims "Google Chrome" alongside one that doesn't is a + // stronger bot signal than the original. Only headers Chromium already + // attached are rewritten — none are added. (navigator.userAgentData JS + // brands still report only Chromium; there is no reliable hook to spoof + // that under sandbox+contextIsolation, and header-based detection is the + // common case.) + const chromeMajor = getChromeMajorVersion(); + const chromeFull = process.versions.chrome ?? `${chromeMajor}.0.0.0`; + const brandLists: Record = { + 'sec-ch-ua': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "Not-A.Brand";v="99"`, + 'sec-ch-ua-full-version-list': `"Chromium";v="${chromeFull}", "Google Chrome";v="${chromeFull}", "Not-A.Brand";v="99.0.0.0"`, + }; + browserSession.webRequest.onBeforeSendHeaders((details, callback) => { + const requestHeaders = details.requestHeaders; + for (const name of Object.keys(requestHeaders)) { + const replacement = brandLists[name.toLowerCase()]; + if (replacement !== undefined) { + requestHeaders[name] = replacement; + } + } + callback({ requestHeaders }); + }); + this.browserSession = browserSession; return browserSession; } @@ -196,17 +269,34 @@ export class BrowserViewManager extends EventEmitter { return /^https?:\/\//i.test(url) || url === 'about:blank'; } + /** + * webPreferences shared by browser tabs and OAuth popups. Kept in one place + * so the security-sensitive popup surface can never drift from tabs. + */ + private browserWebPreferences(): Electron.WebPreferences { + return { + session: this.getSession(), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + // Chromium's built-in PDFium viewer, so PDFs render inline instead + // of showing a blank page. + plugins: true, + // Remove the WebAuthn API from the embedded browser only. Electron ships + // the API but not Chrome's authenticator UI (Touch ID sheet, QR/phone + // hybrid), so passkey challenges hang forever on "Verifying it's you...". + // With the API absent, sites feature-detect it and fall back to + // password/other verification. Scoped here (not app-wide) so the app's + // own renderer keeps WebAuthn. + disableBlinkFeatures: 'WebAuth', + }; + } + private createView(): WebContentsView { const view = new WebContentsView({ - webPreferences: { - session: this.getSession(), - contextIsolation: true, - sandbox: true, - nodeIntegration: false, - }, + webPreferences: this.browserWebPreferences(), }); - view.webContents.setUserAgent(SPOOF_UA); return view; } @@ -266,16 +356,145 @@ export class BrowserViewManager extends EventEmitter { }); wc.on('page-title-updated', this.emitState.bind(this)); - wc.setWindowOpenHandler(({ url }) => { - if (this.isEmbeddedTabUrl(url)) { - void this.newTab(url); - } else { - void shell.openExternal(url); + this.wireWindowPolicy(wc); + } + + /** + * Window-open, popup, and HTTP-auth wiring shared by tabs and popups. + */ + private wireWindowPolicy(wc: WebContents): void { + wc.setWindowOpenHandler((details) => this.handleWindowOpen(details)); + wc.on('did-create-window', (child) => this.wirePopupWindow(child)); + this.wireHttpAuth(wc); + } + + /** + * Shared window.open / target=_blank policy for tabs and popups. + * + * An open that hands a handle back to the opener must become a real child + * window so window.opener / postMessage survive — this is how OAuth/SSO + * popups (Google, Microsoft, Plaid, ...) return their result; denying them + * also makes sites report "popup blocked". Those are: a sized popup + * (disposition 'new-window'), a *named* window.open(url, 'name') (non-empty + * frameName), or a scripted blank window the opener will populate + * (about:blank). A nameless target=_blank link (foreground-tab, empty + * frameName) has no opener contract and opens as a tab, matching browser + * behavior. Non-web schemes go to the system handler. + * + * Residual gap: a nameless, featureless window.open(url) is indistinguishable + * from a _blank link (both foreground-tab + empty frameName) and opens as a + * tab, losing its opener — rare for OAuth, which virtually always names or + * sizes its popup. + */ + private handleWindowOpen(details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse { + const { url, disposition, frameName } = details; + + if (this.isEmbeddedTabUrl(url)) { + const needsOpener = + disposition === 'new-window' || frameName !== '' || url === 'about:blank'; + if (needsOpener) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + autoHideMenuBar: true, + webPreferences: this.browserWebPreferences(), + }, + }; } + void this.newTab(url); return { action: 'deny' }; + } + + void shell.openExternal(url); + return { action: 'deny' }; + } + + private wirePopupWindow(child: BrowserWindow): void { + this.popupWindows.add(child); + child.once('closed', () => this.popupWindows.delete(child)); + this.wireWindowPolicy(child.webContents); + } + + /** True if `win` is an OAuth/SSO popup created by page window.open(). */ + isPopupWindow(win: BrowserWindow): boolean { + return this.popupWindows.has(win); + } + + /** + * HTTP basic/proxy auth. Chromium's default is to cancel the challenge, so + * 401-protected sites and authenticating proxies dead-end. When the browser + * pane is on screen to answer, forward the challenge to it as a credential + * prompt (cancelled after a timeout if unanswered). When the pane is closed + * — e.g. agent-driven navigation — don't preventDefault, so Chromium cancels + * immediately and the 401 page is readable rather than hanging. + */ + private wireHttpAuth(wc: WebContents): void { + wc.on('login', (event, _details, authInfo, callback) => { + if (!this.visible || !this.window) return; + event.preventDefault(); + + const requestId = randomUUID(); + const timer = setTimeout(() => { + this.finishHttpAuth(requestId); + }, HTTP_AUTH_TIMEOUT_MS); + this.pendingHttpAuth.set(requestId, { callback, timer, webContents: wc }); + // If the challenging contents dies before an answer, resolve now so the + // native callback and timer don't leak (backstop for paths other than + // destroyTab, which cancels explicitly before removeAllListeners()). + wc.once('destroyed', () => this.finishHttpAuth(requestId)); + + const request: HttpAuthRequest = { + requestId, + host: authInfo.host, + isProxy: authInfo.isProxy, + ...(authInfo.realm ? { realm: authInfo.realm } : {}), + }; + this.emit('http-auth-request', request); }); } + /** + * Resolve a pending auth challenge. `username === undefined` cancels it; an + * empty-string username is a valid submission (token-style Basic auth). + * Always notifies the renderer so a dialog it may still be showing (e.g. + * after a timeout or tab close) is pruned. + */ + private finishHttpAuth(requestId: string, username?: string, password?: string): boolean { + const pending = this.pendingHttpAuth.get(requestId); + if (!pending) return false; + this.pendingHttpAuth.delete(requestId); + clearTimeout(pending.timer); + try { + if (username == null) { + pending.callback(); + } else { + pending.callback(username, password ?? ''); + } + } catch { + // The challenged webContents may already be destroyed. + } + this.emit('http-auth-resolved', requestId); + return true; + } + + private cancelHttpAuthForWebContents(wc: WebContents): void { + const ids: string[] = []; + for (const [requestId, pending] of this.pendingHttpAuth) { + if (pending.webContents === wc) ids.push(requestId); + } + for (const requestId of ids) { + this.finishHttpAuth(requestId); + } + } + + respondToHttpAuth(input: { + requestId: string; + username?: string; + password?: string; + }): { ok: boolean } { + return { ok: this.finishHttpAuth(input.requestId, input.username, input.password) }; + } + private snapshotTabState(tab: BrowserTab): BrowserTabState { const wc = tab.view.webContents; return { @@ -364,6 +583,10 @@ export class BrowserViewManager extends EventEmitter { private destroyTab(tab: BrowserTab): void { this.invalidateSnapshot(tab.id); + // Cancel any auth challenge this tab raised before we drop its listeners, + // so the native callback + timer don't leak and the renderer prunes its + // dialog (removeAllListeners() below would kill the 'destroyed' backstop). + this.cancelHttpAuthForWebContents(tab.view.webContents); tab.view.webContents.removeAllListeners(); if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close(); diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx index a1270706..9736ea84 100644 --- a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -1,7 +1,19 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react' +import type { HttpAuthRequest } from '@x/shared/dist/browser-control.js' + import { TabBar } from '@/components/tab-bar' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' /** @@ -86,9 +98,80 @@ const getBrowserTabTitle = (tab: BrowserTabState) => { } } +/** + * Credential prompt for HTTP basic/proxy auth challenges raised by pages in + * the embedded browser. Rendered as a regular app dialog: BrowserPane already + * hides the native WebContentsView whenever a dialog overlay is open, so the + * prompt is never obscured by the page that triggered it. + */ +function BrowserHttpAuthDialog({ + request, + onSubmit, + onCancel, +}: { + request: HttpAuthRequest + onSubmit: (username: string, password: string) => void + onCancel: () => void +}) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + // Basic auth allows an empty username (token-style `curl -u :TOKEN`), so the + // only invalid submission is fully empty. The server decides the rest. + const canSubmit = username.length > 0 || password.length > 0 + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!canSubmit) return + onSubmit(username, password) + } + + return ( + { if (!open) onCancel() }}> + + + Sign in + + {request.isProxy + ? `The proxy ${request.host} requires a username and password.` + : `${request.host} requires a username and password.`} + {request.realm ? ` (${request.realm})` : ''} + + +
+ setUsername(e.target.value)} + placeholder="Username" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> + setPassword(e.target.value)} + placeholder="Password" + /> + + + + +
+
+
+ ) +} + export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) { const [state, setState] = useState(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') + const [authQueue, setAuthQueue] = useState([]) const activeTabIdRef = useRef(null) const addressFocusedRef = useRef(false) @@ -121,6 +204,51 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) return cleanup }, [applyState]) + // Mirror of authQueue for the unmount handler, which must read the latest + // queue without re-subscribing on every change. + const authQueueRef = useRef([]) + useEffect(() => { + authQueueRef.current = authQueue + }, [authQueue]) + + useEffect(() => { + const offRequest = window.ipc.on('browser:httpAuthRequest', (incoming) => { + setAuthQueue((queue) => [...queue, incoming as HttpAuthRequest]) + }) + // Main resolved a challenge on its own (timeout, or its tab/window was + // destroyed) — drop the corresponding dialog so it can't linger over an + // unrelated page with a submit that would no-op. + const offResolved = window.ipc.on('browser:httpAuthResolved', (incoming) => { + const { requestId } = incoming as { requestId: string } + setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId)) + }) + return () => { + offRequest() + offResolved() + // Cancel anything still pending so the main-process login callbacks and + // timers are freed immediately instead of waiting out the timeout. + for (const request of authQueueRef.current) { + void window.ipc.invoke('browser:httpAuthResponse', { requestId: request.requestId }) + } + } + }, []) + + const respondToAuth = useCallback( + (requestId: string, credentials: { username: string; password: string } | null) => { + setAuthQueue((queue) => queue.filter((request) => request.requestId !== requestId)) + // Omit username to cancel; include it (even empty) to submit. + void window.ipc.invoke( + 'browser:httpAuthResponse', + credentials + ? { requestId, username: credentials.username, password: credentials.password } + : { requestId }, + ) + }, + [], + ) + + const activeAuthRequest = authQueue[0] ?? null + const setViewVisible = useCallback((visible: boolean) => { if (viewVisibleRef.current === visible) return viewVisibleRef.current = visible @@ -420,6 +548,17 @@ export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) className="relative min-h-0 min-w-0 flex-1" data-browser-viewport /> + + {activeAuthRequest && ( + + respondToAuth(activeAuthRequest.requestId, { username, password }) + } + onCancel={() => respondToAuth(activeAuthRequest.requestId, null)} + /> + )} ) } diff --git a/apps/x/packages/shared/src/browser-control.ts b/apps/x/packages/shared/src/browser-control.ts index e4eb112d..1383e9e2 100644 --- a/apps/x/packages/shared/src/browser-control.ts +++ b/apps/x/packages/shared/src/browser-control.ts @@ -14,6 +14,15 @@ export const BrowserStateSchema = z.object({ tabs: z.array(BrowserTabStateSchema), }); +// HTTP basic/proxy auth challenge raised by a page in the embedded browser. +// Defined once here so main, preload, and renderer share one shape. +export const HttpAuthRequestSchema = z.object({ + requestId: z.string(), + host: z.string(), + isProxy: z.boolean(), + realm: z.string().optional(), +}); + export const BrowserPageElementSchema = z.object({ index: z.number().int().positive(), tagName: z.string(), @@ -134,6 +143,7 @@ export const BrowserControlResultSchema = z.object({ export type BrowserTabState = z.infer; export type BrowserState = z.infer; +export type HttpAuthRequest = z.infer; export type BrowserPageElement = z.infer; export type BrowserPageSnapshot = z.infer; export type BrowserControlAction = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 565ac47a..ba9062c2 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -18,7 +18,7 @@ import { RequestedAgent, type TurnEvent } from './turns.js'; import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js'; import { RowboatApiConfig } from './rowboat-account.js'; import { ZListToolkitsResponse } from './composio.js'; -import { BrowserStateSchema } from './browser-control.js'; +import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js'; @@ -1667,6 +1667,30 @@ const ipcSchemas = { req: BrowserStateSchema, res: z.null(), }, + // HTTP basic/proxy auth challenge from a page in the embedded browser + // (main → renderer push). The renderer shows a credential prompt and + // answers via browser:httpAuthResponse. + 'browser:httpAuthRequest': { + req: HttpAuthRequestSchema, + res: z.null(), + }, + // Main → renderer: a pending auth challenge was resolved without the + // renderer answering (timed out, or its tab/window was destroyed), so the + // renderer must drop the corresponding dialog from its queue. + 'browser:httpAuthResolved': { + req: z.object({ requestId: z.string() }), + res: z.null(), + }, + // Renderer → main. Omit username to cancel the challenge; provide it (even + // empty, for token-style auth) to submit credentials. + 'browser:httpAuthResponse': { + req: z.object({ + requestId: z.string(), + username: z.string().optional(), + password: z.string().optional(), + }), + res: z.object({ ok: z.boolean() }), + }, // Billing channels 'billing:getInfo': { req: z.null(),