diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index ab384903c0d..3443959ef4d 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -145,6 +145,7 @@ case "$group" in export DB_TX_TRIPWIRE=throw export NEXT_PUBLIC_CHAT_DISABLED=true export SIM_LINKS=true + export PEOPLE=true start_app scim 3017 SCIM record-http-status SCIM_E2E_BASE_URL="$local_app_url" \ SCIM_E2E_DATABASE_URL="$DATABASE_URL" \ @@ -161,6 +162,11 @@ case "$group" in SIM_LINKS_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ SIM_LINKS_E2E_REPORT_PATH="$report_dir/sim-links-e2e-report.json" \ bun run test:sim-links:e2e + PEOPLE_E2E_BASE_URL="$local_app_url" \ + PEOPLE_E2E_DATABASE_URL="$DATABASE_URL" \ + PEOPLE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + PEOPLE_E2E_REPORT_PATH="$report_dir/people-e2e-report.json" \ + bun run test:people:e2e ;; # The search suites serve their own fixtures in-process and need no app. They share this group diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75277ba3f97..c2d2c3a5e66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,7 @@ jobs: migrate: permissions: contents: read + id-token: write name: migrate needs: [checks] # Explicit need results instead of the implicit success(): a skipped job @@ -133,6 +134,8 @@ jobs: STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} STAGING_MIGRATION_DATABASE_URL: ${{ secrets.STAGING_MIGRATION_DATABASE_URL }} + TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} + TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }} # Same ordering for dev (schema push before the dev image lands in ECR) migrate-dev: @@ -146,6 +149,8 @@ jobs: environment: dev secrets: DEV_DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }} + TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} + TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }} # Dev: build immutable images alongside the schema push and Trigger upload. image-dev: @@ -1166,6 +1171,9 @@ jobs: # Process docs embeddings (only when docs change, after images are promoted) docs: + permissions: + contents: read + id-token: write name: docs needs: [promote, docs-changes] # Explicit results: see migrate's comment. @@ -1178,6 +1186,8 @@ jobs: secrets: DATABASE_URL: ${{ secrets.DATABASE_URL }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} + TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }} # Create GitHub Release (only for version commits on main, after all builds complete) release: diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index b34719e1bb1..b7d43ae85c1 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -115,6 +115,7 @@ jobs: PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json + CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json - name: Upload test results if: failure() @@ -192,6 +193,7 @@ jobs: env: BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json + CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json - name: Upload test results if: failure() diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index 541d5af8e3f..64b55a001c7 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -226,9 +226,16 @@ jobs: if: always() && steps.build.outcome == 'success' run: | mkdir -p apps/docs/test-results + FEEDBACK_TLS_DIR=$(mktemp -d) + export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem" + export DOCS_FEEDBACK_TEST_KEY="$FEEDBACK_TLS_DIR/key.pem" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null + export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT" + export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback + export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32) bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 & DOCS_SERVER_PID=$! - trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT + trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT STATUS=0 bun run --cwd apps/docs test:negotiation:e2e || STATUS=1 bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 99ec8cf1ce8..b11e5b4f7fd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,10 @@ on: required: false OPENAI_API_KEY: required: false + TS_OAUTH_CLIENT_ID: + required: false + TS_AUDIENCE: + required: false workflow_dispatch: inputs: publish: @@ -40,6 +44,10 @@ jobs: concurrency: group: docs-search-index-production cancel-in-progress: false + permissions: + contents: read + # Tailscale workload identity federation exchanges this job's OIDC token. + id-token: write steps: - name: Record verified deployment and backup @@ -68,6 +76,15 @@ jobs: provider: ${{ vars.CI_PROVIDER }} node-version: '24' + # Database IP restrictions admit the tailnet's app connector; see migrate.yml. + - name: Join the tailnet + if: vars.CI_DB_TAILNET == 'true' + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:ci + - name: Process docs embeddings working-directory: ./apps/docs env: diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 4975ca60334..fcad4344b40 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -18,6 +18,10 @@ on: required: false STAGING_MIGRATION_DATABASE_URL: required: false + TS_OAUTH_CLIENT_ID: + required: false + TS_AUDIENCE: + required: false workflow_dispatch: inputs: environment: @@ -46,6 +50,10 @@ jobs: concurrency: group: migrate-${{ inputs.environment }} cancel-in-progress: false + permissions: + contents: read + # Tailscale workload identity federation exchanges this job's OIDC token. + id-token: write steps: # An environment only migrates from its own branch, so a manual dispatch @@ -73,6 +81,18 @@ jobs: exit 1 fi + # Database IP restrictions admit the tailnet's app connector, never a + # runner's address. The action already passes --accept-routes, which + # Linux nodes need to use app connector routes; repeating it in args + # makes `tailscale up` reject the duplicate flag. + - name: Join the tailnet + if: vars.CI_DB_TAILNET == 'true' + uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:ci + - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7f9415e3fdc..ce6f3212a71 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -171,6 +171,11 @@ tabs and popups select their session from the destination origin. Sign-out and a or server changes use the existing browser teardown. Browser views retain their own permission/download policy, SSRF guards, and minimal preload with no `simDesktop` API. +**Continue in Chrome** (browser menu, plus a hint on known sign-in hosts) opens the active +https tab in a chosen Chromium-family profile and **Bring session back** imports only that +page's registrable-domain cookies from it; the rationale and gating live on +`SimDesktopBrowserImportApi.continueSignIn` in `@sim/desktop-bridge`. + Generated HTML remains inside its `allow-scripts` sandbox. The driver can inspect and interact with inline frames through isolated Chromium worlds, including out-of-process frames; it does not grant those pages access to the parent app. These shell changes diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist index 51ec6a97a57..edf7bcaf4b9 100644 --- a/apps/desktop/build/entitlements.mac.plist +++ b/apps/desktop/build/entitlements.mac.plist @@ -14,10 +14,5 @@ Hardened Runtime denies the camera no matter what the user allowed. --> com.apple.security.device.camera - - com.apple.security.device.bluetooth - diff --git a/apps/desktop/e2e-durations.json b/apps/desktop/e2e-durations.json index 2557a2730a8..e82a13f8ac3 100644 --- a/apps/desktop/e2e-durations.json +++ b/apps/desktop/e2e-durations.json @@ -7,6 +7,7 @@ "chat-panel.spec.ts": 31.08, "check-report.spec.ts": 3.78, "computer-use.spec.ts": 2.07, + "continue-sign-in.spec.ts": 5.8, "desktop-tools-live-sim.spec.ts": 0.0, "local-files.spec.ts": 27.68, "packaged-smoke.spec.ts": 0.0, diff --git a/apps/desktop/e2e/browser-page-parity.spec.ts b/apps/desktop/e2e/browser-page-parity.spec.ts new file mode 100644 index 00000000000..ec1c1256536 --- /dev/null +++ b/apps/desktop/e2e/browser-page-parity.spec.ts @@ -0,0 +1,459 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { createServer, type ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type ElectronApplication, + _electron as electron, + expect, + type Page, + test, +} from '@playwright/test' +import type { BrowserPageDialog, BrowserToolName } from '@sim/browser-protocol' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { getErrorMessage } from '@sim/utils/errors' +import { recordCheck } from './check-report' + +/** + * The embedded browser against what a user expects of Chrome: the page menu, + * history and tab keys, Escape on a slow load, and a video's fullscreen + * button. Each test drives the real shell and native tab views with trusted + * input, and lands in a JSON report at BROWSER_PAGE_PARITY_REPORT_PATH + * (test-results/browser-page-parity-report.json by default) as it finishes. + */ + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'browser-page-parity-e2e' +const IS_MAC = process.platform === 'darwin' +const SHELL_FIXTURE = 'Sim fixture

Browser parity fixture

' +/** A 2x2 PNG, so a saved copy is checked byte for byte. */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4nGP8z8DwnwEIGBkZGRgYAAAhBAEB/0bFxgAAAABJRU5ErkJggg==', + 'base64' +) +const PAGES: Record = { + '/form': `form +`, + '/image': + 'image', + '/links': 'linksLanding', + '/fullscreen': `fullscreen + +`, +} + +type Bridge = typeof globalThis & { + simDesktop: SimDesktopApi + pageDialog?: BrowserPageDialog | null + boundsTimer?: number +} +/** What the suite installs in the main process. */ +type MainFixture = typeof globalThis & { + agentMenus?: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[][] + visibleView?: () => Electron.WebContentsView | undefined +} + +const reportPath = () => + process.env.BROWSER_PAGE_PARITY_REPORT_PATH ?? + join(test.info().project.outputDir, 'browser-page-parity-report.json') + +/** Runs one check and adds its outcome to the report as soon as it is known. */ +async function check(name: string, body: () => Promise): Promise { + const startedAt = Date.now() + const record = (status: 'passed' | 'failed', error?: unknown) => + recordCheck(reportPath(), 'browser-page-parity', { + name, + status, + durationMs: Date.now() - startedAt, + retry: test.info().retry, + ...(error === undefined ? {} : { error: getErrorMessage(error) }), + }) + try { + await body() + record('passed') + } catch (error) { + record('failed', error) + throw error + } +} + +/** A test whose outcome is also one check in the suite's report. */ +function checkedTest(name: string, body: () => Promise): void { + test(name, () => check(name, body)) +} + +test.describe('browser page parity with Chrome', () => { + let app: ElectronApplication | undefined + let shell: Page + let site = '' + let userData = '' + let downloads = '' + /** Held responses for the slow page, released when the suite ends. */ + const held = new Set() + /** The Referer each load of the link target arrived with. */ + const referrers: (string | undefined)[] = [] + /** The Referer each request for the image arrived with. */ + const imageReferrers: (string | undefined)[] = [] + const calls = new Map() + const server = createServer(async (request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/api/desktop/tool/authorize') { + let body = '' + for await (const chunk of request) body += chunk.toString() + const authorization = calls.get(JSON.parse(body).toolCallId) + response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(authorization ?? {})) + return + } + if (path === '/pixel.png') { + imageReferrers.push(request.headers.referer) + // Hotlink-protected, like many image hosts: a request without the page as referrer fails. + if (!request.headers.referer) { + response.writeHead(403) + response.end() + return + } + response.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }) + response.end(PNG) + return + } + if (path === '/landing') referrers.push(request.headers.referer) + if (path === '/slow') { + held.add(response) + return + } + // The app origin serves the shell; the same server on localhost is the web. + const isSite = request.headers.host?.startsWith('localhost') === true + response.writeHead(200, { + 'Content-Type': 'text/html', + ...(isSite ? {} : { 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/' }), + }) + response.end( + isSite + ? (PAGES[path] ?? `${path.slice(1)}

${path}

`) + : SHELL_FIXTURE + ) + }) + + test.beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + const origin = `http://127.0.0.1:${address.port}` + site = origin.replace('127.0.0.1', 'localhost') + userData = mkdtempSync(join(tmpdir(), 'sim-browser-parity-e2e-')) + downloads = mkdtempSync(join(tmpdir(), 'sim-browser-parity-downloads-')) + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, + }) + // A dialog listener stops Playwright auto-dismissing a page's leave-site + // question, so the desktop's own handling decides it as in production. + const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {}) + app.context().pages().forEach(leaveDialogsToDesktop) + app.context().on('page', leaveDialogsToDesktop) + shell = await app.firstWindow() + await app.evaluate(({ app, BrowserWindow, Menu, WebContentsView }, downloads) => { + // Downloads default to the OS folder; keep the suite's files out of the user's. + app.setPath('downloads', downloads) + const host = BrowserWindow.getAllWindows()[0] + host.webContents.setBackgroundThrottling(false) + app.focus({ steal: true }) + host.focus() + // A native menu blocks the main process while it is open; keep each page + // menu's items so a check can choose one the way a click would. + const capture = globalThis as MainFixture + capture.visibleView = () => + BrowserWindow.getAllWindows()[0].contentView.children.find( + (child): child is Electron.WebContentsView => + child instanceof WebContentsView && child.getVisible() + ) + capture.agentMenus = [] + const build = Menu.buildFromTemplate.bind(Menu) + Menu.buildFromTemplate = (template) => { + capture.agentMenus?.push(template) + const menu = build(template) + menu.popup = () => {} + return menu + } + }, downloads) + await expect(shell.getByRole('heading')).toHaveText('Browser parity fixture') + await shell.evaluate(async (scope) => { + const bridge = globalThis as Bridge + const api = bridge.simDesktop.browserAgent + await api.activateScope(scope) + api.onPageState((state) => { + bridge.pageDialog = state.dialog ?? null + }) + const updateBounds = () => + api.setPanelBounds( + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, + null, + scope + ) + updateBounds() + bridge.boundsTimer = window.setInterval(updateBounds, 200) + }, SCOPE) + await execute('browser_open_url', { url: `${site}/start` }) + await panelAction({ action: 'switch-tab', tabId: '1' }) + await panelAction({ action: 'enable-page-dialogs' }) + }) + + test.afterAll(async () => { + for (const response of held) response.destroy() + await app?.close() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(userData, { recursive: true, force: true }) + rmSync(downloads, { recursive: true, force: true }) + }) + + let callCount = 0 + async function execute(tool: BrowserToolName, args: Record) { + const callId = `browser-parity-${++callCount}` + calls.set(callId, { chatId: SCOPE, toolName: tool, args }) + const result = await shell.evaluate( + ({ callId, tool, args, scope }) => + (globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope), + { callId, tool, args, scope: SCOPE } + ) + expect(result.ok, result.error).toBe(true) + } + + /** Browser-chrome actions are user gestures, so each follows a real click in Sim. */ + async function panelAction(action: Record) { + await shell.getByRole('heading').click() + await shell.evaluate( + ({ action, scope }) => + (globalThis as Bridge).simDesktop.browserAgent.panelAction( + action as unknown as Parameters[0], + scope + ), + { action, scope: SCOPE } + ) + } + + /** Reads one property of the page on screen from the main process. */ + function visiblePage(read: string): Promise { + if (!app) throw new Error('App not launched') + return app.evaluate(({ BrowserWindow }, read) => { + const view = (globalThis as MainFixture).visibleView?.() + if (!view) return null + const contents = view.webContents + const values: Record = { + url: contents.getURL(), + title: contents.getTitle(), + loading: contents.isLoadingMainFrame(), + zoom: contents.getZoomFactor(), + bounds: view.getBounds(), + windowFullScreen: BrowserWindow.getAllWindows()[0].isFullScreen(), + } + return values[read] + }, read) as Promise + } + + const pageUrl = () => visiblePage('url') + const pageTitle = () => visiblePage('title') + const pageDialog = () => shell.evaluate(() => (globalThis as Bridge).pageDialog ?? null) + + /** Trusted input into the page on screen, the way the user's own mouse and keys arrive. */ + async function inPage( + action: + | { kind: 'click' | 'right-click'; selector: string; offsetX?: number } + | { kind: 'type'; text: string } + | { kind: 'key'; keyCode: string; modifiers?: string[] } + | { kind: 'script'; script: string } + ): Promise { + if (!app) throw new Error('App not launched') + return app.evaluate(async (_electron, action) => { + const view = (globalThis as MainFixture).visibleView?.() + if (!view) throw new Error('No visible page') + const contents = view.webContents + if (action.kind === 'script') return contents.executeJavaScript(action.script) + if (action.kind === 'type') { + for (const character of action.text) { + contents.sendInputEvent({ type: 'char', keyCode: character }) + } + return null + } + if (action.kind === 'key') { + const input = { + keyCode: action.keyCode, + modifiers: action.modifiers ?? [], + } as Electron.KeyboardInputEvent + contents.focus() + contents.sendInputEvent({ ...input, type: 'keyDown' }) + contents.sendInputEvent({ ...input, type: 'keyUp' }) + return null + } + const rect: { x: number; y: number; height: number } = await contents.executeJavaScript( + `(() => { const { x, y, height } = document.querySelector(${JSON.stringify(action.selector)}).getBoundingClientRect(); return { x, y, height } })()` + ) + const point = { + x: Math.round(rect.x + (action.offsetX ?? 10)), + y: Math.round(rect.y + Math.min(rect.height / 2, 20)), + } + const button = action.kind === 'click' ? 'left' : 'right' + contents.sendInputEvent({ type: 'mouseDown', ...point, button, clickCount: 1 }) + contents.sendInputEvent({ type: 'mouseUp', ...point, button, clickCount: 1 }) + return null + }, action) + } + + /** Right-clicks `selector` and returns the page menu's item labels. */ + async function openPageMenu(selector: string, offsetX?: number): Promise { + if (!app) throw new Error('App not launched') + const menus = () => + app?.evaluate(() => (globalThis as MainFixture).agentMenus?.length ?? 0) ?? 0 + const before = await menus() + await inPage({ kind: 'right-click', selector, offsetX }) + await expect.poll(menus).toBeGreaterThan(before) + return app.evaluate(() => + ((globalThis as MainFixture).agentMenus?.at(-1) ?? []) + .filter((item) => item.type !== 'separator') + .map((item) => item.label ?? '') + ) + } + + /** Chooses an item from the page menu opened last. */ + async function choosePageMenuItem(label: string): Promise { + if (!app) throw new Error('App not launched') + await app.evaluate((_electron, label) => { + const item = (globalThis as MainFixture).agentMenus + ?.at(-1) + ?.find((entry) => entry.label === label) + if (!item?.click) throw new Error(`The page menu has no "${label}" item`) + ;(item.click as () => void)() + }, label) + } + + async function navigate(path: string): Promise { + await panelAction({ action: 'navigate', url: `${site}${path}` }) + await expect.poll(pageUrl).toBe(`${site}${path}`) + await expect.poll(() => visiblePage('loading')).toBe(false) + } + + checkedTest("the page menu's Back asks before leaving unsaved changes", async () => { + await navigate('/next') + await navigate('/form') + await inPage({ kind: 'click', selector: '#draft' }) + await inPage({ kind: 'type', text: 'draft' }) + await expect.poll(() => inPage({ kind: 'script', script: 'draft.value' })).toBe('draft') + + await openPageMenu('#draft', 120) + await choosePageMenuItem('Back') + await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' }) + const stay = await pageDialog() + await panelAction({ action: 'respond-dialog', requestId: stay?.requestId, allowed: false }) + await expect.poll(pageDialog).toBeNull() + expect(await pageUrl()).toBe(`${site}/form`) + expect(await inPage({ kind: 'script', script: 'draft.value' })).toBe('draft') + + await openPageMenu('#draft', 120) + await choosePageMenuItem('Back') + await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' }) + const leave = await pageDialog() + await panelAction({ action: 'respond-dialog', requestId: leave?.requestId, allowed: true }) + await expect.poll(pageUrl).toBe(`${site}/next`) + }) + + checkedTest( + "the page menu's Save Image lands through the browser's downloads, with the page as referrer", + async () => { + for (const file of readdirSync(downloads)) rmSync(join(downloads, file), { force: true }) + await navigate('/image') + await expect + .poll(() => inPage({ kind: 'script', script: 'document.getElementById("pic").complete' })) + .toBe(true) + const labels = await openPageMenu('#pic') + expect(labels).toEqual( + expect.arrayContaining(['Open Image in New Tab', 'Copy Image', 'Copy Image Address']) + ) + imageReferrers.length = 0 + await choosePageMenuItem('Save Image') + await expect.poll(() => readdirSync(downloads)).toEqual(['pixel.png']) + expect(readFileSync(join(downloads, 'pixel.png')).equals(PNG)).toBe(true) + expect(imageReferrers).toEqual([`${site}/image`]) + } + ) + + checkedTest("the page menu's Open Link in New Tab sends the page as referrer", async () => { + referrers.length = 0 + await navigate('/links') + await openPageMenu('#link') + await choosePageMenuItem('Open Link in New Tab') + await expect.poll(() => referrers).toEqual([`${site}/links`]) + }) + + checkedTest('tab keys, and history keys off macOS, typed in the page follow Chrome', async () => { + await navigate('/one') + await execute('browser_open_tab', { url: `${site}/two` }) + await execute('browser_open_tab', { url: `${site}/three` }) + const tabs = await shell.evaluate( + async (scope) => + (await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs, + SCOPE + ) + // Earlier checks may have left tabs open; the keys move to the neighbours in strip order. + const urls = tabs.map((tab) => tab.url) + const index = urls.indexOf(`${site}/one`) + const next = urls[(index + 1) % urls.length] + const previous = urls[(index - 1 + urls.length) % urls.length] + expect(urls.length).toBeGreaterThan(2) + await panelAction({ action: 'switch-tab', tabId: tabs[index]?.tabId }) + await expect.poll(pageUrl).toBe(`${site}/one`) + + if (IS_MAC) { + await inPage({ kind: 'key', keyCode: 'Right', modifiers: ['meta', 'alt'] }) + await expect.poll(pageUrl).toBe(next) + await inPage({ kind: 'key', keyCode: '[', modifiers: ['meta', 'shift'] }) + await expect.poll(pageUrl).toBe(`${site}/one`) + await inPage({ kind: 'key', keyCode: '[', modifiers: ['meta', 'shift'] }) + await expect.poll(pageUrl).toBe(previous) + await inPage({ kind: 'key', keyCode: ']', modifiers: ['meta', 'shift'] }) + await expect.poll(pageUrl).toBe(`${site}/one`) + await inPage({ kind: 'key', keyCode: 'Left', modifiers: ['meta', 'alt'] }) + await expect.poll(pageUrl).toBe(previous) + } else { + await inPage({ kind: 'key', keyCode: 'PageDown', modifiers: ['control'] }) + await expect.poll(pageUrl).toBe(next) + await inPage({ kind: 'key', keyCode: 'PageUp', modifiers: ['control'] }) + await expect.poll(pageUrl).toBe(`${site}/one`) + await navigate('/one-later') + await inPage({ kind: 'key', keyCode: 'Left', modifiers: ['alt'] }) + await expect.poll(pageUrl).toBe(`${site}/one`) + await inPage({ kind: 'key', keyCode: 'Right', modifiers: ['alt'] }) + await expect.poll(pageUrl).toBe(`${site}/one-later`) + } + }) + + checkedTest('Escape stops a page that is still loading', async () => { + await navigate('/before-slow') + await panelAction({ action: 'navigate', url: `${site}/slow` }) + await expect.poll(() => visiblePage('loading')).toBe(true) + await inPage({ kind: 'key', keyCode: 'Escape' }) + await expect.poll(() => visiblePage('loading'), { timeout: 3_000 }).toBe(false) + }) + + checkedTest("a page's fullscreen fills the browser panel and Escape leaves it", async () => { + await navigate('/fullscreen') + const bounds = await visiblePage('bounds') + await inPage({ kind: 'click', selector: '#go' }) + await expect.poll(pageTitle).toBe('entered') + expect(await visiblePage('windowFullScreen')).toBe(false) + expect(await visiblePage('bounds')).toEqual(bounds) + await inPage({ kind: 'key', keyCode: 'Escape' }) + await expect.poll(pageTitle).toBe('exited') + expect(await visiblePage('windowFullScreen')).toBe(false) + }) +}) diff --git a/apps/desktop/e2e/browser-sign-in-prompts.spec.ts b/apps/desktop/e2e/browser-sign-in-prompts.spec.ts new file mode 100644 index 00000000000..d52d13cd97d --- /dev/null +++ b/apps/desktop/e2e/browser-sign-in-prompts.spec.ts @@ -0,0 +1,560 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test' +import type { + BrowserAppLaunchNotice, + BrowserPagePrompt, + BrowserToolName, +} from '@sim/browser-protocol' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { build } from 'esbuild' +import postcss from 'postcss' +import loadPostcssConfig from 'postcss-load-config' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'browser-sign-in-prompts-e2e' +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) +const SHELL_FIXTURE = + 'Sim fixture

Browser sign-in fixture

' +const CREDENTIALS = `Basic ${Buffer.from('alice:s3cret').toString('base64')}` +/** Long enough for a refused request to have surfaced if it were going to. */ +const QUIET_MS = 1_500 + +type Bridge = typeof globalThis & { + simDesktop: SimDesktopApi + pagePrompt?: BrowserPagePrompt | null + launchNotice?: BrowserAppLaunchNotice | null + boundsTimer?: number +} + +type MainGlobals = typeof globalThis & { openedExternal?: string[] } + +/** + * A browser tab's sign-in challenges and links to other apps belong to the + * user: Sim shows the question for the tab that asked, only a click in Sim + * answers it, and nothing is asked of an older renderer that cannot show it. + * These checks drive the real shell, native tab views, and Sim's prompt UI. + */ +test('sign-in and app-launch prompts wait for the user on the page that asked', async () => { + test.setTimeout(180_000) + const reportPath = + process.env.DESKTOP_BROWSER_SIGNIN_REPORT_PATH ?? + test.info().outputPath('browser-sign-in-prompts.json') + const checks: { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string + }[] = [] + const check = async (name: string, run: () => Promise) => { + const started = Date.now() + try { + await test.step(name, run) + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Date.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + const browserComponents = join( + SIM_DIR, + 'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session' + ) + const bundle = await build({ + stdin: { + contents: `import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useBrowserSessionStore as store } from ${JSON.stringify(join(SIM_DIR, 'stores/browser-session/store.ts'))}; +import { BrowserAppLaunchNoticeBar, BrowserPagePromptModal } from ${JSON.stringify(join(browserComponents, 'browser-page-prompt.tsx'))}; +import { useBrowserPanelOcclusion } from ${JSON.stringify(join(browserComponents, 'browser-panel-occlusion.ts'))}; +const scope = ${JSON.stringify(SCOPE)}; +const api = globalThis.simDesktop.browserAgent; +api.onPageState(state => store.getState().setPageState(state)); +store.subscribe(state => { + globalThis.pagePrompt = state.sessions[scope]?.pageState?.prompt ?? null; + globalThis.launchNotice = state.sessions[scope]?.pageState?.appLaunchNotice ?? null; +}); +function Fixture() { + const page = store(state => state.sessions[scope]?.pageState); + useBrowserPanelOcclusion(scope, page?.tabId ?? null, true, () => document.getElementById('native-panel')?.getBoundingClientRect() ?? null); + const respond = (requestId, response) => api.panelAction({ action: 'respond-prompt', requestId, response }, scope); + return createElement('div', null, + page?.appLaunchNotice ? createElement(BrowserAppLaunchNoticeBar, { key: page.appLaunchNotice.requestId, notice: page.appLaunchNotice, onRespond: respond }) : null, + createElement(BrowserPagePromptModal, { prompt: page?.prompt, open: Boolean(page?.prompt), onRespond: respond })); +} +createRoot(document.getElementById('root')).render(createElement(Fixture));`, + resolveDir: SIM_DIR, + loader: 'tsx', + }, + bundle: true, + write: false, + outfile: test.info().outputPath('fixture.js'), + external: ['node:async_hooks'], + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, + format: 'iife', + platform: 'browser', + tsconfig: join(SIM_DIR, 'tsconfig.json'), + define: { 'process.env.NODE_ENV': '"development"' }, + }) + const config = await loadPostcssConfig({}, SIM_DIR) + const stylesheet = join(SIM_DIR, 'app/_styles/globals.css') + const css = await postcss(config.plugins).process(readFileSync(stylesheet, 'utf8'), { + from: stylesheet, + }) + const calls = new Map() + const challenges: string[] = [] + let otherPort = 0 + const handleSite = (request: IncomingMessage, response: ServerResponse, port: number) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/basic') { + challenges.push(`${port}${path}`) + if (request.headers.authorization === CREDENTIALS) { + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('secret') + return + } + response.writeHead(401, { + 'Content-Type': 'text/html', + 'WWW-Authenticate': 'Basic realm="Fixture realm"', + }) + response.end('denied') + return + } + response.writeHead(200, { 'Content-Type': 'text/html' }) + if (path === '/subresource') { + response.end( + `sub` + ) + return + } + if (path === '/links') { + response.end(`links +

Join meeting

+

Diagnostics

+

Unhandled

+

Mail

+`) + return + } + if (path === '/auto') { + response.end( + `auto` + ) + return + } + response.end('next') + } + const server: Server = createServer(async (request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/fixture.js' || path === '/fixture.css') { + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') + response.end( + path.endsWith('.js') + ? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text + : css.css + ) + return + } + if (path === '/api/desktop/tool/authorize') { + let body = '' + for await (const chunk of request) body += chunk.toString() + const authorization = calls.get(JSON.parse(body).toolCallId) + response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(authorization ?? {})) + return + } + // The app origin serves the shell; the same server on localhost is the web. + if (request.headers.host?.startsWith('localhost')) { + const address = server.address() + handleSite(request, response, typeof address === 'object' && address ? address.port : 0) + return + } + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/', + }) + response.end(SHELL_FIXTURE) + }) + const otherSite: Server = createServer((request, response) => + handleSite(request, response, otherPort) + ) + const userData = mkdtempSync(join(tmpdir(), 'sim-browser-signin-e2e-')) + let app: ElectronApplication | undefined + let passed = false + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => otherSite.listen(0, '127.0.0.1', resolve)) + const address = server.address() + const otherAddress = otherSite.address() + if ( + !address || + typeof address === 'string' || + !otherAddress || + typeof otherAddress === 'string' + ) + throw new Error('Missing fixture address') + otherPort = otherAddress.port + const origin = `http://127.0.0.1:${address.port}` + const site = `http://localhost:${address.port}` + const crossSite = `http://localhost:${otherPort}` + const shellApp = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, + }) + app = shellApp + // Launching real apps is out of scope: record what the shell would hand the OS, + // and register a fixture handler for the schemes the page links to. + await shellApp.evaluate(({ app, shell }) => { + const main = globalThis as MainGlobals + main.openedExternal = [] + shell.openExternal = async (url: string) => { + main.openedExternal?.push(url) + } + app.getApplicationNameForProtocol = (url: string) => + /^(simfixture|ms-msdt|mailto):/i.test(url) ? 'Fixture Meetings' : '' + }) + const openedExternal = () => + shellApp.evaluate(() => [...((globalThis as MainGlobals).openedExternal ?? [])]) + const shell = await shellApp.firstWindow() + await shellApp.evaluate(({ app, BrowserWindow }) => { + const host = BrowserWindow.getAllWindows()[0] + if (!host) throw new Error('Missing host window') + app.focus({ steal: true }) + host.focus() + }) + await expect(shell.getByRole('heading')).toHaveText('Browser sign-in fixture') + await shell.evaluate(async (scope) => { + const bridge = globalThis as Bridge + const api = bridge.simDesktop.browserAgent + await api.activateScope(scope) + const updateBounds = () => + api.setPanelBounds( + { x: 0, y: 120, width: innerWidth, height: innerHeight - 120 }, + null, + scope + ) + updateBounds() + bridge.boundsTimer = window.setInterval(updateBounds, 200) + }, SCOPE) + + let callCount = 0 + const startTool = (tool: BrowserToolName, args: Record) => { + const callId = `browser-signin-${++callCount}` + calls.set(callId, { chatId: SCOPE, toolName: tool, args }) + return shell.evaluate( + ({ callId, tool, args, scope }) => + (globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope), + { callId, tool, args, scope: SCOPE } + ) + } + const execute = async (tool: BrowserToolName, args: Record) => { + const result = await startTool(tool, args) + expect(result.ok, result.error).toBe(true) + return result.result + } + /** Browser-chrome actions are user gestures, so each follows a real click in Sim. */ + const panelAction = async (action: Record) => { + await shell.getByRole('heading').click() + await shell.evaluate( + ({ action, scope }) => + (globalThis as Bridge).simDesktop.browserAgent.panelAction( + action as unknown as Parameters[0], + scope + ), + { action, scope: SCOPE } + ) + } + const pagePrompt = () => shell.evaluate(() => (globalThis as Bridge).pagePrompt ?? null) + const launchNotice = () => shell.evaluate(() => (globalThis as Bridge).launchNotice ?? null) + /** The page currently at `url`; `read` is `title` or a script evaluated in it. */ + const pageAt = (url: string, read: string) => + shellApp.evaluate( + async ({ webContents }, { url, read }) => { + const contents = webContents + .getAllWebContents() + .find((candidate) => !candidate.isDestroyed() && candidate.getURL() === url) + if (!contents) return null + if (read === 'title') return contents.getTitle() as T + return (await contents.executeJavaScript(read, true)) as T + }, + { url, read } + ) + const titleAt = (url: string) => () => pageAt(url, 'title') + const navigate = async (url: string) => { + await panelAction({ action: 'navigate', url }) + await expect.poll(() => pageAt(url, 'title')).not.toBeNull() + } + /** Trusted input into the page, the way the user's own click arrives and focuses it. */ + const userClick = (url: string, selector: string) => + shellApp.evaluate( + async ({ webContents }, { url, selector }) => { + const contents = webContents + .getAllWebContents() + .find((candidate) => !candidate.isDestroyed() && candidate.getURL() === url) + if (!contents) throw new Error(`No page at ${url}`) + const rect: { x: number; y: number } = await contents.executeJavaScript( + `(() => { const { x, y } = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x, y } })()` + ) + const point = { x: Math.round(rect.x + 3), y: Math.round(rect.y + 3) } + contents.focus() + contents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 }) + contents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 }) + }, + { url, selector } + ) + /** Types into the visible sign-in prompt the way the user does: the password field unlocks on focus. */ + const signInAs = async (username: string, password: string) => { + const dialog = shell.getByRole('dialog') + await expect(dialog).toHaveCount(1) + await expect(async () => { + await dialog.getByRole('textbox', { name: 'Username' }).fill(username) + await dialog.getByRole('textbox', { name: 'Password' }).click() + await shell.keyboard.press('ControlOrMeta+A') + await shell.keyboard.type(password) + await expect(dialog.getByRole('textbox', { name: 'Username' })).toHaveValue(username, { + timeout: 1_000, + }) + await expect(dialog.getByRole('textbox', { name: 'Password' })).toHaveValue(password, { + timeout: 1_000, + }) + }).toPass({ timeout: 10_000 }) + } + const expectQuiet = async (openedBefore: string[]) => { + await sleep(QUIET_MS) + expect(await pagePrompt()).toBeNull() + expect(await launchNotice()).toBeNull() + expect(await openedExternal()).toEqual(openedBefore) + } + + await execute('browser_open_url', { url: `${site}/next` }) + await panelAction({ action: 'switch-tab', tabId: '1' }) + await expect.poll(titleAt(`${site}/next`)).toBe('next') + + await check('an older renderer leaves sign-in challenges and app links refused', async () => { + await panelAction({ action: 'enable-page-dialogs' }) + await navigate(`${site}/basic`) + await expect.poll(titleAt(`${site}/basic`)).toBe('denied') + expect(await pagePrompt()).toBeNull() + await navigate(`${site}/links`) + await expect.poll(titleAt(`${site}/links`)).toBe('links') + await userClick(`${site}/links`, '#app') + await expectQuiet([]) + }) + + await panelAction({ action: 'enable-page-dialogs', pagePrompts: true }) + + await check( + 'basic auth asks for the page, re-asks after a wrong password, then signs in', + async () => { + await panelAction({ action: 'navigate', url: `${site}/basic` }) + await expect.poll(pagePrompt).toMatchObject({ + kind: 'http-auth', + origin: site, + proxy: false, + insecure: true, + retry: false, + }) + await expect(shell.getByText('Your connection to this site is not private.')).toBeVisible() + await signInAs('alice', 'wrong') + await shell.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect.poll(pagePrompt).toMatchObject({ kind: 'http-auth', retry: true }) + await expect(shell.getByText('Incorrect username or password.')).toBeVisible() + await signInAs('alice', 's3cret') + await shell.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect.poll(titleAt(`${site}/basic`)).toBe('secret') + expect(await pagePrompt()).toBeNull() + } + ) + + await check('a cross-origin subresource challenge never asks', async () => { + const before = challenges.length + await navigate(`${site}/subresource`) + await expect.poll(titleAt(`${site}/subresource`)).toBe('img-failed') + expect(challenges.slice(before)).toContain(`${otherPort}/basic`) + expect(await pagePrompt()).toBeNull() + }) + + await check( + "the agent's sign-in challenge waits for the user instead of being answered", + async () => { + const navigation = startTool('browser_navigate', { url: `${crossSite}/basic` }) + await expect.poll(pagePrompt).toMatchObject({ kind: 'http-auth', origin: crossSite }) + await sleep(QUIET_MS) + expect(await pagePrompt()).toMatchObject({ kind: 'http-auth', retry: false }) + await signInAs('alice', 's3cret') + await shell.getByRole('button', { name: 'Sign in', exact: true }).click() + const result = await navigation + expect(result.ok, result.error).toBe(true) + await expect.poll(titleAt(`${crossSite}/basic`)).toBe('secret') + } + ) + + await check("the agent's clicks never open another app", async () => { + await navigate(`${site}/links`) + const opened = await openedExternal() + const snapshot = await execute('browser_snapshot', {}) + const ref = /link "Join meeting" \[ref=(\d+)\]/.exec( + String((snapshot as { outline?: string }).outline) + )?.[1] + expect(ref, 'snapshot lists the Join meeting link').toBeTruthy() + const result = await execute('browser_click', { elementId: Number(ref) }) + await expectQuiet(opened) + const notices = [ + ...((result as { notices?: string[] }).notices ?? []), + ...(((await execute('browser_snapshot', {})) as { notices?: string[] }).notices ?? []), + ] + expect(notices.join('\n')).toContain('never open from agent actions') + }) + + await check('a link to another app asks after a click and opens only on Open', async () => { + await userClick(`${site}/links`, '#app') + await expect.poll(pagePrompt).toMatchObject({ + kind: 'external-app', + origin: site, + appName: 'Fixture Meetings', + canAlwaysAllow: false, + }) + const open = shell.getByRole('button', { name: 'Open', exact: true }) + await expect(open).toBeVisible() + expect(await openedExternal()).toEqual([]) + await expect(open).toBeEnabled() + await open.click() + await expect.poll(openedExternal).toEqual(['simfixture://join?id=7']) + expect(await pagePrompt()).toBeNull() + }) + + await check('without a click, a page only offers to open the app', async () => { + await navigate(`${site}/auto`) + await expect.poll(launchNotice).toMatchObject({ appName: 'Fixture Meetings', origin: site }) + expect(await pagePrompt()).toBeNull() + expect(await openedExternal()).toEqual(['simfixture://join?id=7']) + await shell.getByRole('button', { name: 'Open', exact: true }).click() + await expect.poll(openedExternal).toEqual(['simfixture://join?id=7', 'simfixture://auto']) + await expect.poll(launchNotice).toBeNull() + }) + + await check('a page cannot keep asking to open apps', async () => { + await navigate(`${site}/links`) + await userClick(`${site}/links`, '#app') + await expectQuiet(['simfixture://join?id=7', 'simfixture://auto']) + }) + + await check( + 'denied and unhandled schemes never open, mail links open after a click', + async () => { + await shell.evaluate( + (scope) => (globalThis as Bridge).simDesktop.browserAgent.openTab?.(scope), + SCOPE + ) + const opened = await openedExternal() + // A fresh document per link: one page's refused launch must not mask the next. + let links = '' + for (const [index, selector] of ['#denied', '#unhandled'].entries()) { + links = `${site}/links?tab=2&link=${index}` + await navigate(links) + await userClick(links, selector) + await expectQuiet(opened) + } + await userClick(links, '#mail') + await expect.poll(openedExternal).toEqual([...opened, 'mailto:a@example.com?subject=Hi']) + expect(await pagePrompt()).toBeNull() + } + ) + + await check('a passkey sign-in lets the user choose the account', async () => { + const passkeyPage = `${site}/next?passkey` + await navigate(passkeyPage) + await shellApp.evaluate(async ({ webContents }) => { + const contents = webContents + .getAllWebContents() + .find((candidate) => candidate.getURL().includes('/next?passkey')) + if (!contents) throw new Error('No passkey page') + if (!contents.debugger.isAttached()) contents.debugger.attach('1.3') + await contents.debugger.sendCommand('WebAuthn.enable', { enableUI: false }) + await contents.debugger.sendCommand('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }) + }) + const created = await pageAt( + passkeyPage, + `(async () => { + const make = (id, name) => navigator.credentials.create({ publicKey: { + challenge: new Uint8Array(16), rp: { id: 'localhost', name: 'Fixture' }, + user: { id: new TextEncoder().encode(id), name, displayName: name }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + } }) + await make('user-ada', 'ada@example.com') + await make('user-grace', 'grace@example.com') + globalThis.passkeyOutcome = 'pending' + navigator.credentials.get({ publicKey: { + challenge: new Uint8Array(16), rpId: 'localhost', userVerification: 'preferred', + } }).then((credential) => new TextDecoder().decode(credential.response.userHandle), + (error) => 'error:' + error.name).then((outcome) => { globalThis.passkeyOutcome = outcome }) + return 'created' + })()` + ) + expect(created).toBe('created') + await expect.poll(pagePrompt).toMatchObject({ + kind: 'passkey-account', + relyingPartyId: 'localhost', + }) + const prompt = await pagePrompt() + expect( + prompt?.kind === 'passkey-account' && prompt.accounts.map((account) => account.name).sort() + ).toEqual(['ada@example.com', 'grace@example.com']) + // A stray Enter must never send an identity: nothing is preselected and Enter does not + // continue. It dismisses when the prompt has keyboard focus and is ignored when it does + // not (as on a CI runner without window focus); either way, refuse and ask again. + const passkeyOutcome = () => pageAt(passkeyPage, 'globalThis.passkeyOutcome') + await shell.keyboard.press('Enter') + await sleep(QUIET_MS) + expect(['pending', 'error:NotAllowedError']).toContain(await passkeyOutcome()) + if ((await passkeyOutcome()) === 'pending') { + await shell.getByRole('button', { name: 'Cancel', exact: true }).click() + } + await expect.poll(passkeyOutcome).toBe('error:NotAllowedError') + await pageAt( + passkeyPage, + `globalThis.passkeyOutcome = 'pending' + navigator.credentials.get({ publicKey: { + challenge: new Uint8Array(16), rpId: 'localhost', userVerification: 'preferred', + } }).then((credential) => new TextDecoder().decode(credential.response.userHandle), + (error) => 'error:' + error.name).then((outcome) => { globalThis.passkeyOutcome = outcome }); 'asked'` + ) + await expect.poll(pagePrompt).toMatchObject({ kind: 'passkey-account' }) + const dialog = shell.getByRole('dialog') + await dialog.getByRole('button').filter({ hasText: 'Choose an account' }).click() + await shell.getByRole('menuitemradio', { name: 'grace@example.com' }).click() + await shell.getByRole('button', { name: 'Continue', exact: true }).click() + await expect.poll(passkeyOutcome).toBe('user-grace') + expect(await pagePrompt()).toBeNull() + }) + passed = true + } finally { + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2)) + await app?.close() + await new Promise((resolve) => server.close(() => resolve())) + await new Promise((resolve) => otherSite.close(() => resolve())) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index 3b573ad9df3..92e5e18d429 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from 'node:fs' -import { createServer, type Server } from 'node:http' +import { createServer, type IncomingMessage, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -62,6 +62,36 @@ const CAPABILITIES_FIXTURE = `Capabilities fixture const POPUP_FIXTURE = `Authorize fixture ` +/** Sign-in and payment popup patterns: blank-then-navigate, and form posts into a new tab. */ +const popupsFixture = ( + appOrigin: string, + popupOrigin: string +) => `Popups fixture +
+
+` + +async function readBody(request: IncomingMessage): Promise { + let body = '' + for await (const chunk of request) body += chunk.toString() + return body +} + +const echoPage = (method: string | undefined, body: string) => + `${method} ${body}` + const UPLOAD_TARGET_FIXTURE = `Upload target fixture
`) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + origin = `http://127.0.0.1:${address.port}` + site = `http://localhost:${address.port}` + }) + + test.beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), 'sim-continue-home-')) + userData = mkdtempSync(join(tmpdir(), 'sim-continue-e2e-')) + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + HOME: home, + SIM_DESKTOP_ORIGIN: origin, + SIM_DESKTOP_USER_DATA: userData, + }, + }) + const page = await app.firstWindow() + await expect(page.getByRole('heading')).toHaveText('Continue fixture') + await page.evaluate(async (scope) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + await api.browserAgent.activateScope(scope) + api.browserAgent.setPanelBounds({ x: 0, y: 120, width: 800, height: 400 }, null, scope) + }, SCOPE) + }) + + test.afterEach(async () => { + const testInfo = test.info() + recordCheck(process.env.CONTINUE_SIGN_IN_REPORT_PATH, 'continue-sign-in', { + name: testInfo.title, + status: testInfo.status ?? 'unknown', + durationMs: testInfo.duration, + retry: testInfo.retry, + }) + await app?.close() + app = undefined + rmSync(userData, { recursive: true, force: true }) + rmSync(home, { recursive: true, force: true }) + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + }) + + test('advertises the capability, but offers no browser when none is installed', async () => { + const page = await app?.firstWindow() + const surface = await page?.evaluate(async () => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return { + continueSignIn: typeof api.browserImport?.continueSignIn, + bringSignInBack: typeof api.browserImport?.bringSignInBack, + pending: await api.browserImport?.getPendingSignIn?.('continue-sign-in-fixture'), + profiles: await api.browserImport?.listChromeProfiles(), + } + }) + + expect(surface).toEqual({ + continueSignIn: 'function', + bringSignInBack: 'function', + pending: null, + profiles: [], + }) + }) + + test('refuses page script that acts without the user', async () => { + const page = await app?.firstWindow() + const results = await page?.evaluate(async (scope) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return { + continued: await api.browserImport?.continueSignIn?.('chrome:Default', scope), + broughtBack: await api.browserImport?.bringSignInBack?.(scope), + } + }, SCOPE) + + expect(results).toEqual({ + continued: { error: 'unknown' }, + broughtBack: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + }) + }) + + test('reads the page from the tab itself and hands over only https', async () => { + const page = await app?.firstWindow() + if (!page || !app) throw new Error('App did not launch') + const read = (key: string) => page.evaluate((name) => Reflect.get(globalThis, name), key) + + await page.locator('#open').click() + await expect.poll(() => read('opened')).toMatchObject({ activeTabId: expect.any(String) }) + await expect + .poll(() => + app?.evaluate( + ({ webContents }, target) => + webContents.getAllWebContents().some((contents) => contents.getURL() === target), + `${site}/login` + ) + ) + .toBe(true) + + await page.locator('#continue').click() + await expect.poll(() => read('continued')).toEqual({ error: 'unsupported-page' }) + + await page.locator('#back').click() + await expect + .poll(() => read('broughtBack')) + .toEqual({ cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }) + }) +}) diff --git a/apps/desktop/e2e/fixtures/updater.ts b/apps/desktop/e2e/fixtures/updater.ts index 33fea2ba5dd..a964dfcc979 100644 --- a/apps/desktop/e2e/fixtures/updater.ts +++ b/apps/desktop/e2e/fixtures/updater.ts @@ -2,23 +2,43 @@ import { writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join, relative } from 'node:path' import type { DesktopUpdateState } from '@sim/desktop-bridge' -import { app, autoUpdater as nativeUpdater, net } from 'electron' +import { app, BrowserWindow, autoUpdater as nativeUpdater, net } from 'electron' import { MacUpdater } from 'electron-updater' +import { countWindowsWithUnsavedChanges, describeInFlightWork } from '@/main/in-flight-work' import { initUpdater } from '@/main/updater' +/** What a window's page does on `beforeunload`, shaped like the web app's own listeners. */ +type FixtureUnloadListener = 'none' | 'passive' | 'unsaved-edits' + +type FixtureDialog = Pick< + Electron.MessageBoxOptions, + 'title' | 'message' | 'buttons' | 'defaultId' | 'cancelId' +> + declare global { var desktopUpdaterFixture: { check(): void install(): void finishStaging(): void failStaging(): void + setWork(work: { + terminalCommands: string[] + browserToolRunning: boolean + unloadListener: FixtureUnloadListener + /** Adds a second window whose renderer is blocked past the shell's probe deadline. */ + hungWindow?: boolean + }): Promise read(): { state: DesktopUpdateState nativeArchive: string | null installed: string[] events: { name: string; data: unknown }[] + dialogs: FixtureDialog[] } } + /** Filled by the spec's `showShellDialog` stub, which answers with `updaterFixtureDialogResponse`. */ + var updaterFixtureDialogs: FixtureDialog[] + var updaterFixtureDialogResponse: number } const directory = process.env.SIM_UPDATER_FIXTURE_DIR @@ -26,7 +46,10 @@ const origin = process.env.SIM_UPDATER_FIXTURE_ORIGIN if (!directory || !origin) throw new Error('Updater fixture configuration is missing') app.setPath('userData', join(directory, 'user-data')) -void app.whenReady().then(() => { +globalThis.updaterFixtureDialogs = [] +globalThis.updaterFixtureDialogResponse = 0 + +void app.whenReady().then(async () => { const configPath = join(directory, 'updater.yml') const cache = relative(join(homedir(), 'Library', 'Caches'), join(directory, 'cache')) writeFileSync(configPath, `updaterCacheDirName: ${JSON.stringify(cache)}\n`) @@ -55,6 +78,14 @@ void app.whenReady().then(() => { installed.push(nativeArchive) } + // A real renderer, so the unsaved-edits probe runs against Chromium's own event dispatch. + const page = new BrowserWindow({ show: false }) + page.webContents.on('will-prevent-unload', (event) => event.preventDefault()) + await page.loadURL('about:blank') + let terminalCommands: string[] = [] + let browserToolRunning = false + let hungPage: BrowserWindow | null = null + const updater = new MacUpdater() updater.forceDevUpdateConfig = true updater.disableDifferentialDownload = true @@ -68,6 +99,18 @@ void app.whenReady().then(() => { canSelfUpdate: async () => true, probeOriginFeed: async () => false, installStatePath: join(directory, 'update-install.json'), + describeInFlightWork: async () => + describeInFlightWork({ + terminalCommands, + uncheckedTerminalCount: 0, + computerUseApp: null, + browserToolRunning, + agentWorkRunning: false, + downloadCount: 0, + ...(await countWindowsWithUnsavedChanges( + hungPage ? [page.webContents, hungPage.webContents] : [page.webContents] + )), + }), }) globalThis.desktopUpdaterFixture = { @@ -78,6 +121,43 @@ void app.whenReady().then(() => { nativeUpdater.emit('update-downloaded', {}, '', nativeArchive, new Date(), '') }, failStaging: () => nativeUpdater.emit('error', new Error('Native verification failed')), - read: () => ({ state: handle.getState(), nativeArchive, installed, events }), + setWork: async (work) => { + terminalCommands = work.terminalCommands + browserToolRunning = work.browserToolRunning + if (hungPage) { + // Let the blocked renderer finish first; destroying it mid-loop stalls the app's exit. + await hungPage.webContents.executeJavaScript('0') + hungPage.destroy() + hungPage = null + } + if (work.hungWindow) { + hungPage = new BrowserWindow({ show: false }) + await hungPage.loadURL('about:blank') + await hungPage.webContents.executeJavaScript(`window.addEventListener('beforeunload', () => { + const until = Date.now() + 1500 + while (Date.now() < until) {} + })`) + } + await page.webContents.executeJavaScript(`(() => { + for (const listener of window.fixtureUnloadListeners ?? []) { + window.removeEventListener('beforeunload', listener) + } + window.fixtureUnloadListeners = [] + const kind = ${JSON.stringify(work.unloadListener)} + if (kind === 'none') return + // 'passive' runs on unload without holding the page, like the chat's handoff cleanup. + const listener = + kind === 'unsaved-edits' ? (event) => event.preventDefault() : () => {} + window.addEventListener('beforeunload', listener) + window.fixtureUnloadListeners.push(listener) + })()`) + }, + read: () => ({ + state: handle.getState(), + nativeArchive, + installed, + events, + dialogs: globalThis.updaterFixtureDialogs, + }), } }) diff --git a/apps/desktop/e2e/updater.spec.ts b/apps/desktop/e2e/updater.spec.ts index ac0be66e98a..65bdaabffcf 100644 --- a/apps/desktop/e2e/updater.spec.ts +++ b/apps/desktop/e2e/updater.spec.ts @@ -10,7 +10,7 @@ import { build } from 'esbuild' const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) -test('the real MacUpdater waits for native staging, replaces old builds, and retries failed staging', async () => { +test('the real MacUpdater waits for native staging, replaces old builds, retries failed staging, and restarts on one click', async () => { test.skip(process.platform !== 'darwin', 'Squirrel.Mac lifecycle') const directory = mkdtempSync(join(tmpdir(), 'sim-updater-e2e-')) const reportPath = @@ -40,6 +40,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret } } let snapshot: unknown + let completed = false const server = createServer((request, response) => { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname requests.push({ path, status: 200 }) @@ -72,15 +73,18 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret tsconfig: join(DESKTOP_DIR, 'tsconfig.json'), plugins: [ { - name: 'approve-fixture-restart', + name: 'record-fixture-dialogs', setup(builder) { builder.onResolve({ filter: /^@\/main\/dialogs$/ }, () => ({ path: 'dialog', namespace: 'fixture', })) builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ - contents: - 'export async function showShellDialog() { return { response: 1, checkboxChecked: false } }', + contents: `export async function showShellDialog(...args) { + const { title, message, buttons, defaultId, cancelId } = args.at(-1) + globalThis.updaterFixtureDialogs.push({ title, message, buttons, defaultId, cancelId }) + return { response: globalThis.updaterFixtureDialogResponse, checkboxChecked: false } + }`, })) }, }, @@ -129,7 +133,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret expect((await read()).state).toEqual({ status: 'error', version: '2.1.0' }) }) - await check('cached retry installs only the verified replacement', async () => { + await check('cached retry stages only the verified replacement', async () => { await shell.evaluate(() => globalThis.desktopUpdaterFixture.check()) await expect .poll(async () => (await read()).state) @@ -137,9 +141,77 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0') await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging()) expect((await read()).state).toEqual({ status: 'ready', version: '2.1.0' }) - await shell.evaluate(() => globalThis.desktopUpdaterFixture.install()) - await expect.poll(async () => (await read()).installed).toEqual(['2.1.0']) }) + + await check( + 'restart asks only about real in-flight work, then installs on one click', + async () => { + /** Sets the work in progress, clicks Restart to update, and answers any prompt with `response`. */ + const installWith = async ( + work: { + terminalCommands?: string[] + browserToolRunning?: boolean + unloadListener: 'passive' | 'unsaved-edits' + hungWindow?: boolean + }, + response: number + ) => { + await shell.evaluate( + async ( + _electron, + [{ terminalCommands = [], browserToolRunning = false, ...rest }, answer] + ) => { + await globalThis.desktopUpdaterFixture.setWork({ + terminalCommands, + browserToolRunning, + ...rest, + }) + globalThis.updaterFixtureDialogResponse = answer + globalThis.desktopUpdaterFixture.install() + }, + [work, response] as const + ) + } + const expectNotNowPrompt = async (count: number, message: string) => { + await expect.poll(async () => (await read()).dialogs.length).toBe(count) + expect((await read()).dialogs.at(-1)).toEqual({ + title: 'Restart to install Sim 2.1.0?', + message, + buttons: ['Restart and Update', 'Not Now'], + defaultId: 0, + cancelId: 1, + }) + // The decline settles asynchronously; nothing may install, and the update stays ready. + await expect + .poll(async () => (await read()).state) + .toEqual({ status: 'ready', version: '2.1.0' }) + expect((await read()).installed).toEqual([]) + } + + await installWith( + { terminalCommands: ['cd /repo && claude "fix the build"'], unloadListener: 'passive' }, + 1 + ) + await expectNotNowPrompt(1, 'claude is still running in a terminal.') + + await installWith({ unloadListener: 'unsaved-edits' }, 1) + await expectNotNowPrompt(2, 'A window has unsaved changes.') + + await installWith( + { browserToolRunning: true, unloadListener: 'passive', hungWindow: true }, + 1 + ) + await expectNotNowPrompt( + 3, + "An agent is using the browser. A window isn't responding and may have unsaved changes." + ) + + // Answering Not Now again would leave nothing installed if a prompt appeared. + await installWith({ unloadListener: 'passive' }, 1) + await expect.poll(async () => (await read()).installed).toEqual(['2.1.0']) + expect((await read()).dialogs).toHaveLength(3) + } + ) snapshot = await read() await check( 'the next process distinguishes an incomplete update from a successful install', @@ -182,6 +254,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret }) } ) + completed = true } finally { if (!snapshot && app) snapshot = await app @@ -192,7 +265,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret reportPath, JSON.stringify( { - passed: checks.length === 4 && checks.every((check) => check.status === 'passed'), + passed: completed && checks.every((check) => check.status === 'passed'), checks, requests, snapshot, diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index c9219a003db..a52cc14977e 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -72,7 +72,6 @@ mac: NSScreenCaptureUsageDescription: Mothership captures approved app windows to understand and verify computer-use actions. NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat and for meetings you join in the built-in browser. NSCameraUsageDescription: Sim uses your camera for meetings you join in the built-in browser, such as Google Meet. - NSBluetoothAlwaysUsageDescription: Sim uses Bluetooth to complete passkey sign-ins with a nearby phone in the built-in browser. entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist notarize: true diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 92826c3aec4..94e75d5bf65 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,8 +41,10 @@ "@sim/utils": "workspace:*", "@xterm/headless": "6.0.0", "electron-updater": "6.8.9", + "lru-cache": "11.5.3", "micromatch": "4.0.8", "safe-regex2": "5.1.2", + "tldts": "7.4.18", "pdf-lib": "1.17.1", "@sim/auth-ui": "workspace:*" }, diff --git a/apps/desktop/src/main/browser-agent/context-menu.test.ts b/apps/desktop/src/main/browser-agent/context-menu.test.ts index 44058879f89..113f1738f05 100644 --- a/apps/desktop/src/main/browser-agent/context-menu.test.ts +++ b/apps/desktop/src/main/browser-agent/context-menu.test.ts @@ -29,7 +29,18 @@ type Page = Parameters[1] type Handlers = Parameters[2] function params(overrides: Partial = {}): Params { - return { selectionText: '', linkURL: '', isEditable: false, editFlags: EDIT_FLAGS, ...overrides } + return { + selectionText: '', + linkURL: '', + isEditable: false, + editFlags: EDIT_FLAGS, + misspelledWord: '', + dictionarySuggestions: [], + mediaType: 'none', + srcURL: '', + hasImageContents: false, + ...overrides, + } } function page(overrides: Partial = {}): Page { @@ -46,13 +57,20 @@ function page(overrides: Partial = {}): Page { function handlers(): Handlers { return { addToChat: vi.fn(), + replaceMisspelling: vi.fn(), + addToDictionary: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + cut: vi.fn(), copy: vi.fn(), paste: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - reload: vi.fn(), + pasteAndMatchStyle: vi.fn(), + selectAll: vi.fn(), + navigate: vi.fn(), openTab: vi.fn(), copyLink: vi.fn(), + copyImage: vi.fn(), + saveImage: vi.fn(), setZoomFactor: vi.fn(), } } @@ -82,11 +100,16 @@ describe('buildAgentContextMenuTemplate', () => { }) describe('attachAgentContextMenu', () => { + const navigationHost = { + history: () => ({ canGoBack: false, canGoForward: false }), + navigate: vi.fn(), + } type ContextMenuListener = (event: unknown, params: Params) => void it('suppresses one agent context menu and immediately allows the next menu', async () => { const contents = new WebContentsView().webContents attachAgentContextMenu(contents, { + ...navigationHost, addToChat: vi.fn(), openTab: vi.fn(), defaultZoomFactor: () => BASE_ZOOM_FACTOR, @@ -110,6 +133,7 @@ describe('attachAgentContextMenu', () => { async (inputEvent) => { const contents = new WebContentsView().webContents attachAgentContextMenu(contents, { + ...navigationHost, addToChat: vi.fn(), openTab: vi.fn(), defaultZoomFactor: () => BASE_ZOOM_FACTOR, diff --git a/apps/desktop/src/main/browser-agent/context-menu.ts b/apps/desktop/src/main/browser-agent/context-menu.ts index de9688e6592..4043da75842 100644 --- a/apps/desktop/src/main/browser-agent/context-menu.ts +++ b/apps/desktop/src/main/browser-agent/context-menu.ts @@ -10,18 +10,21 @@ * while the page keeps rendering, so none of that applies. * * It is also what a browser's page menu is everywhere else, and unlike the - * terminal's hidden textarea, the roles here act on a real page: `copy` and + * terminal's hidden textarea, the edit items act on a real page: `copy` and * `paste` go to the frame that was clicked. */ import { resolveDesktopZoom } from '@sim/desktop-bridge' -import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron' +import type { ContextMenuParams, MenuItemConstructorOptions, Referrer, WebContents } from 'electron' import { clipboard, Menu } from 'electron' import { clearAgentContextMenu, consumeAgentContextMenu, isDispatchingAgentInput, } from '@/main/browser-agent/cdp' +import { menuReferrer, referrerForRequest } from '@/main/browser-agent/referrer' +import { spellingItems } from '@/main/context-menu' +import { parseHttpUrl } from '@/main/navigation' /** * Page-zoom ladder for the embedded browser, in Chromium's absolute zoom @@ -78,7 +81,15 @@ export function steppedZoomFactor(current: number, direction: 1 | -1): number { /** The parts of a right-click the menu acts on. */ type AgentContextMenuParams = Pick< ContextMenuParams, - 'selectionText' | 'linkURL' | 'isEditable' | 'editFlags' + | 'selectionText' + | 'linkURL' + | 'isEditable' + | 'editFlags' + | 'misspelledWord' + | 'dictionarySuggestions' + | 'mediaType' + | 'srcURL' + | 'hasImageContents' > /** What the page can currently do, read at the moment of the click. */ @@ -89,34 +100,62 @@ interface AgentPageContext { defaultZoomFactor: number } +/** A history move or reload started from browser chrome rather than by the page. */ +export type PageNavigation = 'back' | 'forward' | 'reload' + +/** + * Edit actions are explicit calls on this page rather than menu roles: a role + * acts on whichever contents holds focus, and a right-click need not focus the page. + */ interface AgentContextMenuHandlers { addToChat(text: string): void + replaceMisspelling(word: string): void + addToDictionary(word: string): void + undo(): void + redo(): void + cut(): void copy(): void paste(): void - back(): void - forward(): void - reload(): void + pasteAndMatchStyle(): void + selectAll(): void + navigate(kind: PageNavigation): void openTab(url: string): void copyLink(url: string): void + copyImage(): void + saveImage(url: string): void setZoomFactor(factor: number): void } export interface AgentContextMenuHost { /** Attaches selected page text to the chat that owns this browser tab. */ addToChat(text: string): void - /** Opens a link from the page in another tab of the same browser. */ - openTab(url: string): void + /** Opens a URL from the page in another tab of the same browser. */ + openTab(url: string, referrer: Referrer): void /** Returns the device's current default page zoom factor. */ defaultZoomFactor(): number + history(): Pick + /** Runs through the user-navigation path, so a page's leave-site question reaches the user. */ + navigate(kind: PageNavigation): void +} + +const isWebUrl = (url: string) => parseHttpUrl(url) !== null + +/** Ends the template with one separator, never two in a row or one at the top. */ +function endGroup(template: MenuItemConstructorOptions[]): void { + if (template.length > 0 && template[template.length - 1].type !== 'separator') { + template.push({ type: 'separator' }) + } } /** * Builds the page menu. Unlike the main window's menu this is never empty — - * navigation and zoom always apply, and only the clipboard and link items - * depend on what was clicked. + * navigation and zoom always apply, and only the spelling, clipboard, link, and + * image items depend on what was clicked. * - * Only http(s) links get link items: the actions open a browser tab or copy an - * address, and neither means anything for a `javascript:` or `mailto:` target. + * Only http(s) links and image sources get address items: the actions open a + * browser tab, download, or copy an address, and none of them means anything + * for a `javascript:`, `data:`, or `blob:` target. Copy Image needs no address, + * so it is offered for any loaded image. */ export function buildAgentContextMenuTemplate( params: AgentContextMenuParams, @@ -124,8 +163,13 @@ export function buildAgentContextMenuTemplate( handlers: AgentContextMenuHandlers ): MenuItemConstructorOptions[] { const template: MenuItemConstructorOptions[] = [] - const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : '' + const linkUrl = isWebUrl(params.linkURL) ? params.linkURL : '' + const imageUrl = isWebUrl(params.srcURL) ? params.srcURL : '' const selectionText = params.selectionText + const flags = params.editFlags + + template.push(...spellingItems(params, handlers)) + endGroup(template) if (selectionText.trim()) { template.push( @@ -142,20 +186,47 @@ export function buildAgentContextMenuTemplate( ) } - if (selectionText.trim()) { - template.push({ label: 'Copy', click: () => handlers.copy() }) - } - if (params.isEditable && params.editFlags.canPaste) { - template.push({ label: 'Paste', click: () => handlers.paste() }) + if (params.mediaType === 'image') { + const copyImage: MenuItemConstructorOptions[] = params.hasImageContents + ? [{ label: 'Copy Image', click: () => handlers.copyImage() }] + : [] + template.push( + ...(imageUrl + ? [ + { label: 'Open Image in New Tab', click: () => handlers.openTab(imageUrl) }, + { label: 'Save Image', click: () => handlers.saveImage(imageUrl) }, + ...copyImage, + { label: 'Copy Image Address', click: () => handlers.copyLink(imageUrl) }, + ] + : copyImage) + ) + endGroup(template) } - if (template.length > 0 && template[template.length - 1].type !== 'separator') { - template.push({ type: 'separator' }) + + if (params.isEditable) { + template.push( + { label: 'Undo', enabled: flags.canUndo, click: () => handlers.undo() }, + { label: 'Redo', enabled: flags.canRedo, click: () => handlers.redo() }, + { type: 'separator' }, + { label: 'Cut', enabled: flags.canCut, click: () => handlers.cut() }, + { label: 'Copy', enabled: flags.canCopy, click: () => handlers.copy() }, + { label: 'Paste', enabled: flags.canPaste, click: () => handlers.paste() }, + { + label: 'Paste and Match Style', + enabled: flags.canPaste, + click: () => handlers.pasteAndMatchStyle(), + }, + { label: 'Select All', enabled: flags.canSelectAll, click: () => handlers.selectAll() } + ) + } else if (selectionText.trim()) { + template.push({ label: 'Copy', click: () => handlers.copy() }) } + endGroup(template) template.push( - { label: 'Back', enabled: page.canGoBack, click: () => handlers.back() }, - { label: 'Forward', enabled: page.canGoForward, click: () => handlers.forward() }, - { label: 'Reload', click: () => handlers.reload() }, + { label: 'Back', enabled: page.canGoBack, click: () => handlers.navigate('back') }, + { label: 'Forward', enabled: page.canGoForward, click: () => handlers.navigate('forward') }, + { label: 'Reload', click: () => handlers.navigate('reload') }, { type: 'separator' } ) @@ -198,20 +269,31 @@ export function attachAgentContextMenu(contents: WebContents, host: AgentContext const template = buildAgentContextMenuTemplate( params, { - canGoBack: contents.navigationHistory.canGoBack(), - canGoForward: contents.navigationHistory.canGoForward(), + ...host.history(), zoomFactor: contents.getZoomFactor(), defaultZoomFactor: host.defaultZoomFactor(), }, { addToChat: (text) => host.addToChat(text), + replaceMisspelling: (word) => contents.replaceMisspelling(word), + addToDictionary: (word) => contents.session.addWordToSpellCheckerDictionary(word), + undo: () => contents.undo(), + redo: () => contents.redo(), + cut: () => contents.cut(), copy: () => contents.copy(), paste: () => contents.paste(), - back: () => contents.navigationHistory.goBack(), - forward: () => contents.navigationHistory.goForward(), - reload: () => contents.reload(), - openTab: (url) => host.openTab(url), + pasteAndMatchStyle: () => contents.pasteAndMatchStyle(), + selectAll: () => contents.selectAll(), + navigate: (kind) => host.navigate(kind), + openTab: (url) => host.openTab(url, menuReferrer(params)), copyLink: (url) => clipboard.writeText(url), + copyImage: () => contents.copyImageAt(params.x, params.y), + // A download, so the browser's download limits and request guards apply. Electron maps a + // Referer header into the download's referrer, so referrer-protected images still save. + saveImage: (url) => { + const referrer = referrerForRequest(menuReferrer(params), url) + contents.downloadURL(url, referrer ? { headers: { Referer: referrer } } : undefined) + }, setZoomFactor: (factor) => contents.setZoomFactor(factor), } ) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 03ec50d8d57..fccd280a9ec 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -804,7 +804,7 @@ describe('executeTool', () => { const popup = session.activeTab()?.view.webContents if (!popup) throw new Error('Expected user popup tab') expect(session.automationTab()?.view.webContents).toBe(source) - expect(popup.loadURL).toHaveBeenCalledWith('https://user-popup.example/') + expect(popup.loadURL).toHaveBeenCalledWith('https://user-popup.example/', expect.anything()) }) it('keeps agent-opened target-blank tabs agent-owned after dispatch ends', async () => { @@ -823,7 +823,7 @@ describe('executeTool', () => { expect(session.activeTab()?.view.webContents).toBe(source) session.setAutomationActive(false) expect(session.automationTab()?.view.webContents).toBe(popup) - expect(popup.loadURL).toHaveBeenCalledWith('https://agent-popup.example/') + expect(popup.loadURL).toHaveBeenCalledWith('https://agent-popup.example/', expect.anything()) }) it('does not report a timed-out restored tab as ready to the model', async () => { @@ -1150,6 +1150,31 @@ describe('executeTool', () => { } }) + it('reports a browser action as running from queueing until it settles', async () => { + vi.useFakeTimers() + try { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + expect(driver.isBrowserToolRunning()).toBe(false) + const queued = driver.captureBrowserToolQueueBoundary('chat-test') + expect(driver.isBrowserToolRunning()).toBe(true) + if (queued) driver.releaseBrowserToolQueueBoundary(queued) + expect(driver.isBrowserToolRunning()).toBe(false) + const contents = session.requireTab().view.webContents + vi.mocked(contents.getURL).mockReturnValue('https://example.com/') + vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {})) + + const held = driver.executeTool('chat-test', 'browser_snapshot', {}, 'held-running') + await vi.advanceTimersByTimeAsync(0) + expect(driver.isBrowserToolRunning()).toBe(true) + + driver.cancelTool('chat-test', 'held-running') + await held + expect(driver.isBrowserToolRunning()).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('sanitizes hostile tab titles before returning them across the tool boundary', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const contents = session.requireTab().view.webContents diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 595d7369c05..d0ada80bf5f 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -81,6 +81,11 @@ import { setFocusedInputValue, typeIntoElement, } from '@/main/browser-agent/page-functions' +import { + appLaunchNoticeForContents, + pagePromptForContents, + respondToPagePrompt, +} from '@/main/browser-agent/page-prompts' import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel' import { withFailedPostActionObservation, @@ -457,6 +462,11 @@ function releaseBrowserToolAdmission(state: DriverScopeState, admission: symbol) activeBrowserToolAdmissions.delete(admission) } +/** Whether any chat has a browser action queued or running in this process. */ +export function isBrowserToolRunning(): boolean { + return activeBrowserToolAdmissions.size > 0 || pendingBrowserToolQueueBoundaries.size > 0 +} + function retireDriverScopeState(state: DriverScopeState): void { state.disposed = true state.toolQueueCancellationEpoch++ @@ -555,6 +565,8 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { const issue = session.pageIssueForContents(contents) const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents) const dialog = session.pageDialogForContents(contents) + const prompt = pagePromptForContents(contents) + const appLaunchNotice = appLaunchNoticeForContents(contents) return { scopeId: session.getBrowserScopeId(), tabId, @@ -566,6 +578,8 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { ...(issue ? { issue } : {}), ...(mediaPermissionRequest ? { mediaPermissionRequest } : {}), ...(dialog ? { dialog } : {}), + ...(prompt ? { prompt } : {}), + ...(appLaunchNotice ? { appLaunchNotice } : {}), } } @@ -804,6 +818,7 @@ export function initDriver( }) }, onDownloadsChanged: (state) => callbacks.onDownloadsChanged?.(state), + onAgentNotice: recordNotice, }, getMainWindow, persistence, @@ -5500,6 +5515,11 @@ export async function handlePanelAction( } if (action.action === 'enable-page-dialogs') { session.enablePageDialogs() + if (action.pagePrompts === true) session.enablePagePrompts() + return + } + if (action.action === 'respond-prompt') { + respondToPagePrompt(session.getBrowserScopeId(), action) return } if (action.action === 'respond-dialog') { diff --git a/apps/desktop/src/main/browser-agent/known-sessions.ts b/apps/desktop/src/main/browser-agent/known-sessions.ts index e248faf0ad0..9a98d8e268c 100644 --- a/apps/desktop/src/main/browser-agent/known-sessions.ts +++ b/apps/desktop/src/main/browser-agent/known-sessions.ts @@ -4,6 +4,7 @@ import type { BrowserKnownSessionsState, BrowserSessionEvidence, } from '@sim/browser-protocol' +import { isSameOrSubdomain } from '@/main/browser-import/site-scope' import type { BrowserKnownSiteSetting, ConfigStore, DesktopSettings } from '@/main/config' const MAX_STORED_SITES = 100 @@ -107,7 +108,7 @@ function recordsEqual( function cookieMatchesHost(cookieDomain: string, hostname: string): boolean { const domain = normalizePublicHostname(cookieDomain) - return domain !== null && (hostname === domain || hostname.endsWith(`.${domain}`)) + return domain !== null && isSameOrSubdomain(hostname, domain) } /** diff --git a/apps/desktop/src/main/browser-agent/page-prompts.test.ts b/apps/desktop/src/main/browser-agent/page-prompts.test.ts new file mode 100644 index 00000000000..0c97f95fc3d --- /dev/null +++ b/apps/desktop/src/main/browser-agent/page-prompts.test.ts @@ -0,0 +1,430 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { BrowserPagePrompt, BrowserPagePromptResponse } from '@sim/browser-protocol' +import type { + AuthenticationResponseDetails, + AuthInfo, + Certificate, + Session, + WebContents, + WebFrameMain, +} from 'electron' +import { app, webContents as electronWebContents, shell } from 'electron' +import { + appLaunchNoticeForContents, + handleBrowserLogin, + handleSelectClientCertificate, + handleSelectPasskeyAccount, + httpAuthChallenge, + noteMainFrameNavigation, + pagePromptForContents, + requestExternalLaunch, + resetPagePromptDecisions, + respondToPagePrompt, + setPagePromptHost, +} from '@/main/browser-agent/page-prompts' +import { registerAgentWebContents } from '@/main/browser-agent/registry' + +const PAGE = 'https://meet.example' + +interface Context { + promptsEnabled: boolean + onScreen: boolean + userActivated: boolean + agentActedLast: boolean + agentDrives: boolean +} + +let context: Context + +function agentContents(session: Session = {} as Session): WebContents { + const contents = { + isDestroyed: () => false, + getURL: () => `${PAGE}/j/1`, + mainFrame: { frameTreeNodeId: 1 }, + session, + } as WebContents + registerAgentWebContents(contents) + return contents +} + +function shownPrompt(contents: WebContents): BrowserPagePrompt { + const prompt = pagePromptForContents(contents) + if (!prompt) throw new Error('No prompt shown') + return prompt +} + +function answer(contents: WebContents, response: BrowserPagePromptResponse, scopeId = 'chat') { + respondToPagePrompt(scopeId, { + action: 'respond-prompt', + requestId: shownPrompt(contents).requestId, + response, + }) +} + +beforeEach(() => { + vi.useFakeTimers() + context = { + promptsEnabled: true, + onScreen: true, + userActivated: false, + agentActedLast: false, + agentDrives: false, + } + setPagePromptHost({ + tabContext: () => ({ scopeId: 'chat', ...context }), + publish: () => {}, + focus: () => {}, + noticeForAgent: () => {}, + }) +}) + +afterEach(() => { + setPagePromptHost(null) + resetPagePromptDecisions() + vi.useRealTimers() +}) + +describe('httpAuthChallenge', () => { + const url = 'https://intranet.example:8443/reports' + const page = { url, isRequestForNavigation: true } + const server = { isProxy: false, host: 'intranet.example', port: 8443 } + + it("asks about the tab's own top-level navigation, challenged by the host it requested", () => { + expect(httpAuthChallenge(page, server, url)).toEqual({ + origin: 'https://intranet.example:8443', + proxy: false, + insecure: false, + }) + expect( + httpAuthChallenge( + { url: 'http://router.local/', isRequestForNavigation: true }, + { isProxy: false, host: 'router.local', port: 80 }, + 'http://router.local/' + ) + ).toMatchObject({ insecure: true }) + }) + + it('cancels frame, subresource, and cross-origin challenges', () => { + expect(httpAuthChallenge(page, server, 'https://intranet.example:8443/')).toBeNull() + expect(httpAuthChallenge(page, server, undefined)).toBeNull() + expect(httpAuthChallenge({ ...page, isRequestForNavigation: false }, server, url)).toBeNull() + expect(httpAuthChallenge(page, { ...server, host: 'evil.example' }, url)).toBeNull() + expect(httpAuthChallenge(page, { ...server, port: 443 }, url)).toBeNull() + }) + + it('asks about a proxy for any request', () => { + expect( + httpAuthChallenge( + { url: 'https://example.com/pixel.png', isRequestForNavigation: false }, + { isProxy: true, host: 'proxy.corp', port: 3128 }, + undefined + ) + ).toEqual({ origin: 'proxy.corp:3128', proxy: true, insecure: false }) + }) +}) + +describe('HTTP sign-in', () => { + const url = 'https://intranet.example/' + const login = (contents: WebContents, firstAuthAttempt = true) => { + const callback = vi.fn() + handleBrowserLogin( + { preventDefault: () => {} }, + contents, + { url, isRequestForNavigation: true, firstAuthAttempt } as AuthenticationResponseDetails, + { + isProxy: false, + scheme: 'basic', + host: 'intranet.example', + port: 443, + realm: '', + } as AuthInfo, + callback + ) + return callback + } + + it('stops asking a page that keeps reloading into the same challenge', () => { + const contents = agentContents() + for (let attempt = 0; attempt < 3; attempt++) { + noteMainFrameNavigation(contents, url) + const callback = login(contents) + expect(pagePromptForContents(contents)).toMatchObject({ kind: 'http-auth' }) + answer(contents, { allowed: false }) + expect(callback).toHaveBeenCalledExactlyOnceWith() + } + noteMainFrameNavigation(contents, url) + expect(login(contents)).toHaveBeenCalledExactlyOnceWith() + expect(pagePromptForContents(contents)).toBeUndefined() + + vi.advanceTimersByTime(60_000) + login(contents) + expect(pagePromptForContents(contents)).toMatchObject({ kind: 'http-auth' }) + }) + + it('passes on only the credentials of an accepted answer', () => { + const contents = agentContents() + noteMainFrameNavigation(contents, url) + const callback = login(contents) + answer(contents, { allowed: true, kind: 'http-auth', username: 'ada', password: 'pw' }) + expect(callback).toHaveBeenCalledExactlyOnceWith('ada', 'pw') + }) + + it('cancels instead of leaving the page waiting when an accepted answer is malformed', () => { + const contents = agentContents() + noteMainFrameNavigation(contents, url) + const callback = login(contents) + // double-cast-allowed: an IPC payload is untyped; this is a renderer's malformed answer + answer(contents, { allowed: true, kind: 'http-auth' } as unknown as BrowserPagePromptResponse) + expect(callback).toHaveBeenCalledExactlyOnceWith() + expect(pagePromptForContents(contents)).toBeUndefined() + }) +}) + +describe('client certificates', () => { + const certificate = (fingerprint: string, subjectName: string) => + ({ fingerprint, subjectName, issuerName: 'Corp CA', validExpiry: 2_000_000_000 }) as Certificate + const first = certificate('sha256/first', 'Personal') + const work = certificate('sha256/work', 'Work badge') + + function request(contents: WebContents | null, list: Certificate[], url = 'https://mtls.corp') { + const event = { preventDefault: vi.fn() } + const callback = vi.fn() + handleSelectClientCertificate(event, contents, url, list, callback) + return { event, callback } + } + + it("leaves the main window on Electron's default", () => { + const { event, callback } = request({ isDestroyed: () => false } as WebContents, [first]) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(callback).not.toHaveBeenCalled() + }) + + it('sends no certificate when there is none, or no renderer to ask', () => { + const contents = agentContents() + const empty = request(contents, []) + expect(empty.event.preventDefault).toHaveBeenCalled() + expect(empty.callback).toHaveBeenCalledExactlyOnceWith() + + context.promptsEnabled = false + expect(request(contents, [first, work]).callback).toHaveBeenCalledExactlyOnceWith() + }) + + it('sends only the picked certificate, remembers it per host, and forgets it on reset', () => { + const session = {} as Session + const contents = agentContents(session) + const { callback } = request(contents, [first, work]) + expect(shownPrompt(contents)).toMatchObject({ kind: 'client-certificate', host: 'mtls.corp' }) + answer(contents, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/work' }) + expect(callback).toHaveBeenCalledExactlyOnceWith(work) + + expect(request(agentContents(session), [first, work]).callback).toHaveBeenCalledWith(work) + expect( + request(agentContents(session), [first, work], 'https://other.corp').callback + ).not.toHaveBeenCalled() + + const otherSession = {} as Session + const otherContents = agentContents(otherSession) + request(otherContents, [first]) + answer(otherContents, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' }) + + resetPagePromptDecisions(session) + expect(request(agentContents(session), [first, work]).callback).not.toHaveBeenCalled() + expect(request(agentContents(otherSession), [first]).callback).toHaveBeenCalledWith(first) + + resetPagePromptDecisions() + expect(request(agentContents(otherSession), [first]).callback).not.toHaveBeenCalled() + }) + + it("remembers Don't send, but not a closed picker or a prompt nobody answered", () => { + const declined = agentContents() + const refused = request(declined, [first]) + answer(declined, { allowed: false, remember: true }) + expect(refused.callback).toHaveBeenCalledExactlyOnceWith() + expect(request(agentContents(declined.session), [first]).callback).toHaveBeenCalledWith() + + const closed = agentContents() + const dismissed = request(closed, [first]) + answer(closed, { allowed: false }) + expect(dismissed.callback).toHaveBeenCalledExactlyOnceWith() + expect(request(agentContents(closed.session), [first]).callback).not.toHaveBeenCalled() + + const ignored = agentContents() + const unanswered = request(ignored, [first]) + vi.advanceTimersByTime(120_000) + expect(unanswered.callback).toHaveBeenCalledExactlyOnceWith() + expect(request(agentContents(ignored.session), [first]).callback).not.toHaveBeenCalled() + }) + + it('sends nothing for an unknown choice, an off-screen answer, or another chat', () => { + const forged = agentContents() + const unknown = request(forged, [first]) + answer(forged, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/forged' }) + expect(unknown.callback).toHaveBeenCalledExactlyOnceWith() + + const elsewhere = agentContents() + const otherChat = request(elsewhere, [first]) + answer(elsewhere, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' }, 'x') + expect(otherChat.callback).not.toHaveBeenCalled() + + context.onScreen = false + const hidden = agentContents() + const offScreen = request(hidden, [first]) + answer(hidden, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' }) + expect(offScreen.callback).toHaveBeenCalledExactlyOnceWith() + }) +}) + +describe('app links', () => { + const topFrame = (origin = PAGE) => ({ parent: null, frameTreeNodeId: 1, origin }) as WebFrameMain + + beforeEach(() => { + app.getApplicationNameForProtocol = vi.fn(() => 'Zoom') + vi.mocked(shell.openExternal).mockClear() + context.userActivated = true + }) + + it('refuses a launch a frame initiated, even one targeting the top document', () => { + const contents = agentContents() + const frame = { + parent: topFrame(), + frameTreeNodeId: 2, + origin: 'https://ads.example', + } as WebFrameMain + requestExternalLaunch(contents, 'zoommtg://zoom.us/join', frame) + requestExternalLaunch(contents, 'zoommtg://zoom.us/join', topFrame('https://ads.example')) + requestExternalLaunch(contents, 'zoommtg://zoom.us/join', null) + expect(pagePromptForContents(contents)).toBeUndefined() + expect(appLaunchNoticeForContents(contents)).toBeUndefined() + }) + + it('asks after the top document is clicked, and otherwise only offers passively', () => { + const clicked = agentContents() + requestExternalLaunch(clicked, 'zoommtg://zoom.us/join', topFrame()) + expect(pagePromptForContents(clicked)).toMatchObject({ kind: 'external-app', appName: 'Zoom' }) + + context.userActivated = false + const unclicked = agentContents() + requestExternalLaunch(unclicked, 'zoommtg://zoom.us/join', topFrame()) + expect(pagePromptForContents(unclicked)).toBeUndefined() + expect(appLaunchNoticeForContents(unclicked)).toMatchObject({ origin: PAGE, appName: 'Zoom' }) + }) + + it('opens nothing, not even a clicked mail link, without a renderer that shows prompts', async () => { + context.promptsEnabled = false + requestExternalLaunch(agentContents(), 'mailto:ada@example.com', topFrame()) + await vi.advanceTimersByTimeAsync(0) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('opens only the first link while its prompt is pending', async () => { + const contents = agentContents() + requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=1', topFrame()) + requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=2', topFrame()) + expect(appLaunchNoticeForContents(contents)).toBeUndefined() + answer(contents, { allowed: true, kind: 'external-app', alwaysAllow: false }) + await vi.advanceTimersByTimeAsync(0) + expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join?id=1') + }) + + it('forgets "Always allow" on reset', async () => { + const contents = agentContents() + requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=1', topFrame()) + answer(contents, { allowed: true, kind: 'external-app', alwaysAllow: true }) + resetPagePromptDecisions() + vi.advanceTimersByTime(20_000) + requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=2', topFrame()) + await vi.advanceTimersByTimeAsync(0) + expect(pagePromptForContents(contents)).toMatchObject({ kind: 'external-app' }) + expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join?id=1') + }) +}) + +describe('accepting an answer', () => { + const topFrame = { parent: null, frameTreeNodeId: 1, origin: PAGE } as WebFrameMain + + /** What a renderer could send over IPC without `allowed: true`, which the gesture gate keys on. */ + function withoutAllowed(response: Record): BrowserPagePromptResponse { + // double-cast-allowed: an IPC payload is untyped; this is the shape a hostile renderer could send + return response as unknown as BrowserPagePromptResponse + } + + it.each([ + [ + 'client-certificate', + { kind: 'client-certificate', choiceId: 'sha256/first' }, + (contents: WebContents, callback: () => void) => + handleSelectClientCertificate( + { preventDefault: () => {} }, + contents, + 'https://mtls.corp', + [{ fingerprint: 'sha256/first', subjectName: 'Personal' } as Certificate], + callback + ), + ], + [ + 'passkey-account', + { kind: 'passkey-account', choiceId: 'credential-1' }, + (contents: WebContents, callback: () => void) => { + vi.mocked(electronWebContents.fromFrame).mockReturnValue(contents) + handleSelectPasskeyAccount( + {}, + { + relyingPartyId: 'meet.example', + accounts: [{ credentialId: 'credential-1', name: 'ada', userHandle: 'u1' }], + frame: topFrame, + }, + callback + ) + }, + ], + [ + 'http-auth', + { kind: 'http-auth', username: 'ada', password: 'pw' }, + (contents: WebContents, callback: () => void) => { + noteMainFrameNavigation(contents, 'https://intranet.example/') + handleBrowserLogin( + { preventDefault: () => {} }, + contents, + { + url: 'https://intranet.example/', + isRequestForNavigation: true, + firstAuthAttempt: true, + } as AuthenticationResponseDetails, + { isProxy: false, scheme: 'basic', host: 'intranet.example', port: 443 } as AuthInfo, + callback + ) + }, + ], + ])('sends nothing for a %s answer without allowed: true', (_kind, response, ask) => { + const contents = agentContents() + const callback = vi.fn() + ask(contents, callback) + answer(contents, withoutAllowed(response)) + expect(callback).toHaveBeenCalledExactlyOnceWith() + }) + + it.each([ + ['an app prompt', { kind: 'external-app', alwaysAllow: true }, 'prompt'], + ['an app notice', { kind: 'app-launch-notice' }, 'notice'], + ])('opens nothing for %s answer without allowed: true', async (_label, response, slot) => { + app.getApplicationNameForProtocol = vi.fn(() => 'Zoom') + vi.mocked(shell.openExternal).mockClear() + context.userActivated = slot === 'prompt' + const contents = agentContents() + requestExternalLaunch(contents, 'zoommtg://zoom.us/join', topFrame) + const requestId = + slot === 'prompt' + ? shownPrompt(contents).requestId + : appLaunchNoticeForContents(contents)?.requestId + respondToPagePrompt('chat', { + action: 'respond-prompt', + requestId, + response: withoutAllowed(response), + }) + await vi.advanceTimersByTimeAsync(0) + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/browser-agent/page-prompts.ts b/apps/desktop/src/main/browser-agent/page-prompts.ts new file mode 100644 index 00000000000..822c703ba84 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/page-prompts.ts @@ -0,0 +1,603 @@ +/** + * Sign-in and app-launch decisions a browser tab's page waits on: HTTP + * authentication, passkey account choice, client certificates, and links to + * other apps. Sim's renderer shows each one for the exact tab that asked; only + * a user-activated renderer response answers it, and every request is refused + * when the renderer cannot show it, does not answer in time, or the page moves + * on to another document. + */ +import type { + BrowserAppLaunchNotice, + BrowserPagePrompt, + BrowserPagePromptResponse, + BrowserPanelAction, +} from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' +import type { + AuthenticationResponseDetails, + AuthInfo, + Certificate, + Session, + WebAuthnAccount, + WebContents, + WebFrameMain, +} from 'electron' +import { app, webContents as electronWebContents } from 'electron' +import { LRUCache } from 'lru-cache' +import { isAgentWebContents } from '@/main/browser-agent/registry' +import { withinWindow } from '@/main/input-activity' +import { appLinkTarget, openExternalSafe, parseHttpUrl } from '@/main/navigation' + +const logger = createLogger('BrowserPagePrompts') + +/** Long enough to type a password; a request never waits on a renderer beyond it. */ +const PAGE_PROMPT_TIMEOUT_MS = 120_000 +const APP_LAUNCH_NOTICE_TIMEOUT_MS = 60_000 +/** Prompts per tab and origin per window, across reloads as well as rejected attempts. */ +const MAX_HTTP_AUTH_PROMPTS = 3 +const HTTP_AUTH_PROMPT_WINDOW_MS = 60_000 +const MAX_CREDENTIAL_LENGTH = 1_024 +const MAX_DISPLAY_TEXT_LENGTH = 120 +const MAX_PROMPT_CHOICES = 50 +const LAUNCH_RATE_WINDOW_MS = 20_000 +const MAX_LAUNCHES_PER_WINDOW = 2 +const MAX_REMEMBERED_DECISIONS = 200 +/** Controls and bidi overrides that let untrusted text break or reorder a line. */ +const UNTRUSTED_TEXT_CONTROLS = /[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g + +interface PageTabContext { + scopeId: string + /** The chat's renderer declared that it shows page prompts. */ + promptsEnabled: boolean + /** The visible page of the visible chat. */ + onScreen: boolean + /** The user's real click or keypress reached this document moments ago, after any agent input. */ + userActivated: boolean + /** The agent's input is being delivered, or was the latest input in this tab. */ + agentActedLast: boolean + /** The agent drives this tab and the user has not claimed it. */ + agentDrives: boolean +} + +/** The session's side of a tab; injected to keep the dependency one-way. */ +interface PagePromptHost { + tabContext(contents: WebContents): PageTabContext | null + publish(contents: WebContents): void + focus(contents: WebContents): void + noticeForAgent(contents: WebContents, notice: string): void +} + +type RequestKind = BrowserPagePrompt['kind'] | 'app-launch-notice' +/** `null` is a cancellation: timeout, navigation, or an answer the user could not have given. */ +type RequestAnswer = (response: BrowserPagePromptResponse | null) => void +type WithoutRequestId = View extends unknown ? Omit : never + +interface PendingRequest { + id: string + contents: WebContents + kind: RequestKind + view: BrowserPagePrompt | BrowserAppLaunchNotice + key: string + answers: RequestAnswer[] + timer: ReturnType +} + +interface TabRequests { + prompt?: PendingRequest + notice?: PendingRequest +} + +let host: PagePromptHost | null = null +const pendingById = new Map() +const pendingByContents = new WeakMap() +const mainFrameNavigations = new WeakMap() +const recentAttemptsByContents = new WeakMap>() +/** A host's remembered certificate, or `'declined'` for an explicit "Don't send". */ +type CertificateDecision = { fingerprint: string } | 'declined' +let certificateDecisions = new WeakMap>() +const alwaysAllowedLaunches = new LRUCache({ max: MAX_REMEMBERED_DECISIONS }) + +export function setPagePromptHost(next: PagePromptHost | null): void { + host = next + if (next) return + for (const entry of [...pendingById.values()]) settle(entry, null) +} + +/** Forgets remembered certificate and app-launch choices, as a sign-out or data wipe must. */ +export function resetPagePromptDecisions(session?: Session): void { + if (session) certificateDecisions.delete(session) + else certificateDecisions = new WeakMap() + alwaysAllowedLaunches.clear() +} + +/** The live list of this tab's recent attempts under `key`, pruned to the window. */ +function recentAttempts(contents: WebContents, key: string, windowMs: number): number[] { + const byKey = + recentAttemptsByContents.get(contents) ?? + new LRUCache({ max: MAX_REMEMBERED_DECISIONS }) + recentAttemptsByContents.set(contents, byKey) + const recent = (byKey.get(key) ?? []).filter((at) => withinWindow(at, windowMs)) + byKey.set(key, recent) + return recent +} + +/** Untrusted page or server text, reduced to one bounded display line. */ +function displayText(value: unknown, maxLength = MAX_DISPLAY_TEXT_LENGTH): string { + if (typeof value !== 'string') return '' + return truncate( + value.replace(UNTRUSTED_TEXT_CONTROLS, ' ').replace(/\s+/g, ' ').trim(), + maxLength + ) +} + +/** Distinct, bounded choices keyed by a non-empty string id. */ +function uniqueChoices( + items: readonly Item[], + idOf: (item: Item) => unknown, + toChoice: (id: string, item: Item) => Choice +): Choice[] { + const seen = new Set() + const choices: Choice[] = [] + for (const item of items) { + const id = idOf(item) + if (typeof id !== 'string' || !id || id.length > MAX_CREDENTIAL_LENGTH || seen.has(id)) continue + seen.add(id) + choices.push(toChoice(id, item)) + if (choices.length >= MAX_PROMPT_CHOICES) break + } + return choices +} + +/** + * Answers Electron exactly once. `ask` returns true once it owns the answer; + * otherwise, and on any error, the request is refused. + */ +function withPrompt( + label: string, + callback: (...args: Args | []) => void, + ask: (answer: (...args: Args | []) => void) => boolean +): void { + let answered = false + const answer = (...args: Args | []) => { + if (answered) return + answered = true + try { + callback(...args) + } catch (error) { + logger.warn(`Could not answer ${label}`, { error: getErrorMessage(error) }) + } + } + let owned = false + try { + owned = ask(answer) + } catch (error) { + logger.warn(`Could not ask about ${label}`, { error: getErrorMessage(error) }) + } finally { + if (!owned) answer() + } +} + +/** + * Holds a request for the user. A tab shows one prompt and one notice at a + * time: a new notice replaces the old one, and a prompt only joins an identical + * pending challenge when `joinable`. False when it cannot be shown. + */ +function hold( + contents: WebContents, + kind: RequestKind, + view: WithoutRequestId | WithoutRequestId, + key: string, + answer: RequestAnswer, + { joinable = false, focus = false }: { joinable?: boolean; focus?: boolean } = {} +): 'held' | 'joined' | false { + if (!host || contents.isDestroyed() || !host.tabContext(contents)?.promptsEnabled) return false + const requests = pendingByContents.get(contents) ?? {} + pendingByContents.set(contents, requests) + const slot = kind === 'app-launch-notice' ? 'notice' : 'prompt' + const existing = requests[slot] + if (existing && slot === 'prompt') { + if (!joinable || existing.key !== key) return false + existing.answers.push(answer) + return 'joined' + } + if (existing) settle(existing, null) + const id = generateId() + const entry: PendingRequest = { + id, + contents, + kind, + view: { ...view, requestId: id } as BrowserPagePrompt | BrowserAppLaunchNotice, + key, + answers: [answer], + timer: setTimeout( + () => settle(entry, null), + slot === 'notice' ? APP_LAUNCH_NOTICE_TIMEOUT_MS : PAGE_PROMPT_TIMEOUT_MS + ), + } + pendingById.set(id, entry) + requests[slot] = entry + host.publish(contents) + if (focus) host.focus(contents) + return 'held' +} + +function settle(entry: PendingRequest, response: BrowserPagePromptResponse | null): void { + if (pendingById.get(entry.id) !== entry) return + pendingById.delete(entry.id) + const requests = pendingByContents.get(entry.contents) + if (requests?.prompt === entry) requests.prompt = undefined + if (requests?.notice === entry) requests.notice = undefined + clearTimeout(entry.timer) + if (!entry.contents.isDestroyed()) host?.publish(entry.contents) + for (const answer of entry.answers) { + try { + answer(response) + } catch (error) { + logger.warn('Browser page prompt answer failed', { error: getErrorMessage(error) }) + // A malformed response must still release the page, which otherwise waits forever. + if (response !== null) { + try { + answer(null) + } catch {} + } + } + } +} + +/** The decision this tab's page waits on, for its page state. */ +export function pagePromptForContents(contents: WebContents): BrowserPagePrompt | undefined { + return pendingByContents.get(contents)?.prompt?.view as BrowserPagePrompt | undefined +} + +export function appLaunchNoticeForContents( + contents: WebContents +): BrowserAppLaunchNotice | undefined { + return pendingByContents.get(contents)?.notice?.view as BrowserAppLaunchNotice | undefined +} + +/** Refuses what the previous document asked for: the tab moved on, crashed, or closed. */ +export function cancelPagePrompts(contents: WebContents): void { + const requests = pendingByContents.get(contents) + if (requests?.prompt) settle(requests.prompt, null) + if (requests?.notice) settle(requests.notice, null) +} + +/** + * Tracks the URL the tab's top-level navigation is requesting (null once it + * commits, fails, or the tab stops loading), so HTTP authentication can tell its + * own challenge from a frame's. + */ +export function noteMainFrameNavigation(contents: WebContents, url: string | null): void { + if (url) mainFrameNavigations.set(contents, url) + else mainFrameNavigations.delete(contents) +} + +/** The renderer's answer for chat `scopeId`; only an on-screen answer of the request's own kind accepts. */ +export function respondToPagePrompt(scopeId: string, action: BrowserPanelAction): void { + const entry = typeof action.requestId === 'string' ? pendingById.get(action.requestId) : undefined + if (!entry || entry.contents.isDestroyed()) return + const context = host?.tabContext(entry.contents) + if (!context || context.scopeId !== scopeId) return + const { response } = action + if (response?.allowed === false) { + settle(entry, response) + return + } + const accepted = response?.allowed === true && response.kind === entry.kind && context.onScreen + settle(entry, accepted ? response : null) +} + +/** + * A challenge worth asking the user about, or null to cancel it: only the + * tab's own top-level navigation, challenged by the host and port it + * requested, or a proxy. Frames, subresources, and cross-origin challenges + * never get a prompt. + */ +export function httpAuthChallenge( + details: Pick, + authInfo: Pick, + mainFrameUrl: string | undefined +): { origin: string; proxy: boolean; insecure: boolean } | null { + const challengedHost = authInfo.host.toLowerCase().replace(/^\[|\]$/g, '') + if (!challengedHost || !Number.isInteger(authInfo.port) || authInfo.port <= 0) return null + if (authInfo.isProxy) { + const hostLabel = challengedHost.includes(':') ? `[${challengedHost}]` : challengedHost + return { origin: `${hostLabel}:${authInfo.port}`, proxy: true, insecure: false } + } + if (!details.isRequestForNavigation || details.url !== mainFrameUrl) return null + const url = parseHttpUrl(details.url) + if (!url) return null + const requestedPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80)) + const requestedHost = url.hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (requestedHost !== challengedHost || requestedPort !== authInfo.port) return null + return { origin: url.origin, proxy: false, insecure: url.protocol === 'http:' } +} + +/** + * `app.on('login')` for agent browser tabs. Other contents keep Electron's + * default, which cancels: the main window's proxy prompt needs a shell form. + * The prompt never takes focus, since a page can trigger it on its own. + */ +export function handleBrowserLogin( + event: { preventDefault(): void }, + contents: WebContents | null, + details: AuthenticationResponseDetails, + authInfo: AuthInfo, + callback: (username?: string, password?: string) => void +): void { + if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return + event.preventDefault() + withPrompt('HTTP sign-in', callback, (answer) => { + const challenge = httpAuthChallenge(details, authInfo, mainFrameNavigations.get(contents)) + if (!challenge) return false + const prompts = recentAttempts( + contents, + `http-auth|${challenge.origin}`, + HTTP_AUTH_PROMPT_WINDOW_MS + ) + if (prompts.length >= MAX_HTTP_AUTH_PROMPTS) return false + const held = hold( + contents, + 'http-auth', + { kind: 'http-auth', ...challenge, retry: !details.firstAuthAttempt }, + `http-auth|${challenge.proxy}|${challenge.origin}|${authInfo.scheme}`, + (response) => { + if ( + response?.allowed === true && + response.kind === 'http-auth' && + typeof response.username === 'string' && + typeof response.password === 'string' && + response.username.length <= MAX_CREDENTIAL_LENGTH && + response.password.length <= MAX_CREDENTIAL_LENGTH + ) { + answer(response.username, response.password) + } else { + answer() + } + }, + { joinable: true } + ) + if (held === 'held') { + prompts.push(Date.now()) + if (host?.tabContext(contents)?.agentDrives) { + host.noticeForAgent( + contents, + `${challenge.origin} asked for a username and password. The user was asked to sign in in the browser panel; wait for them instead of retrying or entering credentials.` + ) + } + } + return Boolean(held) + }) +} + +/** + * `select-webauthn-account` for every session a browser tab uses, the borrowed + * app session included. Without a listener Electron cancels every discoverable + * passkey sign-in; requests from any non-browser contents still cancel. The + * prompt never takes focus: a page can ask without the user's involvement. + */ +export function handleSelectPasskeyAccount( + _event: unknown, + details: { relyingPartyId: string; accounts: WebAuthnAccount[]; frame: WebFrameMain | null }, + callback: (credentialId?: string | null) => void +): void { + withPrompt('a passkey account choice', callback, (answer) => { + const contents = details.frame ? electronWebContents.fromFrame(details.frame) : undefined + if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return false + const relyingPartyId = displayText(details.relyingPartyId, 253) + const accounts = uniqueChoices( + details.accounts ?? [], + (account) => account?.credentialId, + (id, account) => ({ + id, + name: displayText(account.name), + displayName: displayText(account.displayName), + }) + ) + if (accounts.length === 0 || !relyingPartyId) return false + const held = hold( + contents, + 'passkey-account', + { kind: 'passkey-account', relyingPartyId, accounts }, + `passkey|${relyingPartyId}|${accounts.map((account) => account.id).join(',')}`, + (response) => { + const chosen = + response?.allowed === true && response.kind === 'passkey-account' + ? accounts.find((account) => account.id === response.choiceId) + : undefined + if (chosen) answer(chosen.id) + else answer() + }, + { joinable: true } + ) + return Boolean(held) + }) +} + +/** + * `app.on('select-client-certificate')` for agent browser tabs. Electron's + * default sends the first certificate to any site that asks; a browser tab + * sends one only after the user picks it, and the prompt never takes focus, + * since a subresource can ask. The main window keeps the default. + */ +export function handleSelectClientCertificate( + event: { preventDefault(): void }, + contents: WebContents | null, + url: string, + certificates: Certificate[], + callback: (certificate?: Certificate) => void +): void { + if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return + event.preventDefault() + withPrompt('a client certificate choice', callback, (answer) => { + const requestUrl = parseHttpUrl(url) + const requestHost = requestUrl?.protocol === 'https:' ? requestUrl.host.toLowerCase() : null + const choices = uniqueChoices( + certificates, + (certificate) => certificate?.fingerprint, + (id, certificate) => ({ + id, + subject: displayText(certificate.subjectName || certificate.subject?.commonName), + issuer: displayText(certificate.issuerName || certificate.issuer?.commonName), + expiresAt: Number.isFinite(certificate.validExpiry) ? certificate.validExpiry * 1000 : 0, + }) + ) + if (!requestHost || choices.length === 0) return false + const decisions = + certificateDecisions.get(contents.session) ?? + new LRUCache({ max: MAX_REMEMBERED_DECISIONS }) + certificateDecisions.set(contents.session, decisions) + const remembered = decisions.get(requestHost) + if (remembered === 'declined') return false + const rememberedCertificate = remembered + ? certificates.find((certificate) => certificate.fingerprint === remembered.fingerprint) + : undefined + if (rememberedCertificate) { + answer(rememberedCertificate) + return true + } + const held = hold( + contents, + 'client-certificate', + { kind: 'client-certificate', host: requestHost, certificates: choices }, + `client-certificate|${requestHost}`, + (response) => { + if (response?.allowed === false) { + // Only an explicit "Don't send" sticks; closing the picker just cancels this request. + if (response.remember === true) decisions.set(requestHost, 'declined') + answer() + return + } + const certificate = + response?.allowed === true && + response.kind === 'client-certificate' && + typeof response.choiceId === 'string' + ? certificates.find((candidate) => candidate.fingerprint === response.choiceId) + : undefined + if (!certificate) { + answer() + return + } + decisions.set(requestHost, { fingerprint: certificate.fingerprint }) + answer(certificate) + }, + { joinable: true } + ) + return Boolean(held) + }) +} + +/** The tab's own top-level document asked, not a frame inside it. */ +function isTopDocument( + contents: WebContents, + frame: WebFrameMain | null | undefined, + origin: string +): boolean { + try { + return Boolean( + frame && + frame.parent === null && + frame.frameTreeNodeId === contents.mainFrame.frameTreeNodeId && + frame.origin === origin + ) + } catch { + return false + } +} + +function launchAppLink(url: string): void { + void openExternalSafe(url, false, { appLink: true }) +} + +/** + * A browser tab's navigation to a non-web URL, with the frame that initiated + * it. Agent input never launches anything, and neither do frames; `window.open` + * never reaches here, since it names no initiating frame. The top document's + * request right after the user's own click asks them to confirm the app (mail + * links open directly); one without that click only gets a passive notice the + * user can act on. Nothing launches without a renderer that shows prompts, and + * a pending launch prompt refuses further requests. + */ +export function requestExternalLaunch( + contents: WebContents, + rawUrl: string, + initiator: WebFrameMain | null | undefined +): void { + if (!host || contents.isDestroyed() || !isAgentWebContents(contents)) return + const target = appLinkTarget(rawUrl) + if (!target) { + logger.warn('Denied external app link in agent browser', { + scheme: displayText(rawUrl.split(':', 1)[0], 32), + }) + return + } + let appName = '' + try { + appName = displayText(app.getApplicationNameForProtocol(target.url), 80) + } catch {} + const context = host.tabContext(contents) + if (!appName || !context?.promptsEnabled) return + if (context.agentActedLast) { + host.noticeForAgent( + contents, + `The page tried to open ${appName}. Links to other apps never open from agent actions; ask the user to open it themselves if the task needs it.` + ) + return + } + const origin = parseHttpUrl(contents.getURL())?.origin + if (!origin) return + if (!isTopDocument(contents, initiator, origin)) { + logger.warn('Denied an app link from a frame in agent browser', { scheme: target.scheme }) + return + } + const attempts = recentAttempts(contents, 'app-launch', LAUNCH_RATE_WINDOW_MS) + if (attempts.length >= MAX_LAUNCHES_PER_WINDOW) return + const launchKey = `${origin}|${target.scheme}` + if (context.userActivated && !context.agentDrives) { + if (target.scheme === 'mailto' || alwaysAllowedLaunches.has(launchKey)) { + attempts.push(Date.now()) + launchAppLink(target.url) + return + } + const canAlwaysAllow = origin.startsWith('https:') + const held = hold( + contents, + 'external-app', + { kind: 'external-app', origin, appName, canAlwaysAllow }, + `external-app|${launchKey}`, + (response) => { + if (response?.allowed !== true || response.kind !== 'external-app') return + if (response.alwaysAllow && canAlwaysAllow) { + alwaysAllowedLaunches.set(launchKey, true) + } + launchAppLink(target.url) + }, + // The prompt answers the user's own click in the page, so it takes the focus that click had. + { focus: true } + ) + if (held) attempts.push(Date.now()) + return + } + const held = hold( + contents, + 'app-launch-notice', + { origin, appName }, + `app-launch-notice|${launchKey}`, + (response) => { + if (response?.allowed === true && response.kind === 'app-launch-notice') { + launchAppLink(target.url) + } + } + ) + if (!held) return + attempts.push(Date.now()) + if (context.agentDrives) { + host.noticeForAgent( + contents, + `The page offered to open ${appName}. Only the user can open it, from the notice in the browser panel.` + ) + } +} diff --git a/apps/desktop/src/main/browser-agent/referrer.test.ts b/apps/desktop/src/main/browser-agent/referrer.test.ts new file mode 100644 index 00000000000..3f41335f3f5 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/referrer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { referrerForRequest } from '@/main/browser-agent/referrer' + +describe('referrerForRequest', () => { + const PAGE = 'https://user:secret@news.example.com/story?id=7#comments' + const FULL = 'https://news.example.com/story?id=7' + const ORIGIN = 'https://news.example.com/' + + it('sends the full URL same-origin and only the origin cross-origin by default', () => { + const referrer = { url: PAGE, policy: 'default' } as const + expect(referrerForRequest(referrer, 'https://news.example.com/img.png')).toBe(FULL) + expect(referrerForRequest(referrer, 'https://cdn.example.net/img.png')).toBe(ORIGIN) + }) + + it('never sends credentials or the fragment, even under unsafe-url', () => { + expect( + referrerForRequest({ url: PAGE, policy: 'unsafe-url' }, 'http://cdn.example.net/img.png') + ).toBe(FULL) + }) + + it('sends nothing from an https page to an http image unless the policy allows downgrades', () => { + const image = 'http://cdn.example.net/img.png' + for (const policy of [ + 'default', + 'strict-origin-when-cross-origin', + 'strict-origin', + 'no-referrer-when-downgrade', + ] as const) { + expect(referrerForRequest({ url: PAGE, policy }, image)).toBeUndefined() + } + expect(referrerForRequest({ url: PAGE, policy: 'origin' }, image)).toBe(ORIGIN) + expect(referrerForRequest({ url: PAGE, policy: 'no-referrer-when-downgrade' }, FULL)).toBe(FULL) + }) + + it('honors no-referrer and same-origin', () => { + const crossOrigin = 'https://cdn.example.net/img.png' + expect(referrerForRequest({ url: PAGE, policy: 'no-referrer' }, FULL)).toBeUndefined() + expect(referrerForRequest({ url: PAGE, policy: 'same-origin' }, crossOrigin)).toBeUndefined() + expect(referrerForRequest({ url: PAGE, policy: 'same-origin' }, FULL)).toBe(FULL) + }) + + it('cuts a referrer longer than 4096 characters to its origin', () => { + const longPage = `https://news.example.com/story?q=${'x'.repeat(4096)}` + expect( + referrerForRequest({ url: longPage, policy: 'unsafe-url' }, 'https://cdn.example.net/img.png') + ).toBe(ORIGIN) + }) + + it('sends nothing for a page that is not on the web', () => { + expect( + referrerForRequest({ url: '', policy: 'unsafe-url' }, 'https://cdn.example.net/img.png') + ).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/browser-agent/referrer.ts b/apps/desktop/src/main/browser-agent/referrer.ts new file mode 100644 index 00000000000..f897144aad0 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/referrer.ts @@ -0,0 +1,81 @@ +/** + * The referrer a page-menu action carries: the clicked frame's URL and Referrer-Policy, and the + * `Referer` that policy allows for a given request. + */ +import type { ContextMenuParams, Referrer } from 'electron' +import { parseHttpUrl } from '@/main/navigation' + +const isWebUrl = (url: string) => parseHttpUrl(url) !== null + +const REFERRER_POLICIES: ReadonlySet = new Set([ + 'default', + 'unsafe-url', + 'no-referrer-when-downgrade', + 'no-referrer', + 'origin', + 'strict-origin-when-cross-origin', + 'same-origin', + 'strict-origin', +]) + +function isReferrerPolicy(value: unknown): value is Referrer['policy'] { + return REFERRER_POLICIES.has(value) +} + +/** Electron types `referrerPolicy` as a Referrer, but it arrives as the bare policy string. */ +export function menuReferrer( + params: Pick +): Referrer { + const policy: unknown = params.referrerPolicy + const url = params.frameURL || params.pageURL + return { + url: isWebUrl(url) ? url : '', + policy: isReferrerPolicy(policy) ? policy : 'default', + } +} + +/** Longest referrer sent in full; a longer one is cut to its origin, as browsers do. */ +const MAX_REFERRER_LENGTH = 4096 +const LOOPBACK_HOSTS: ReadonlySet = new Set(['localhost', '127.0.0.1', '[::1]']) + +function isPotentiallyTrustworthy(url: URL): boolean { + if (url.protocol === 'https:') return true + return LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith('.localhost') +} + +/** + * The `Referer` a request from `referrer`'s document to `targetUrl` carries under its + * Referrer-Policy (W3C Referrer Policy, "determine request's referrer"), or undefined when it + * sends none. Chromium's `default` is `strict-origin-when-cross-origin`; Electron's `Referrer` has + * no `origin-when-cross-origin`, which {@link menuReferrer} already reads as `default`. + */ +export function referrerForRequest(referrer: Referrer, targetUrl: string): string | undefined { + const source = parseHttpUrl(referrer.url) + const target = parseHttpUrl(targetUrl) + if (!source || !target) return undefined + source.username = '' + source.password = '' + source.hash = '' + const origin = `${source.origin}/` + const full = source.href.length > MAX_REFERRER_LENGTH ? origin : source.href + const sameOrigin = source.origin === target.origin + const downgrade = isPotentiallyTrustworthy(source) && !isPotentiallyTrustworthy(target) + + switch (referrer.policy) { + case 'no-referrer': + return undefined + case 'origin': + return origin + case 'unsafe-url': + return full + case 'same-origin': + return sameOrigin ? full : undefined + case 'no-referrer-when-downgrade': + return downgrade ? undefined : full + case 'strict-origin': + return downgrade ? undefined : origin + default: + if (downgrade) return undefined + return sameOrigin ? full : origin + } +} diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 10cc182f244..6f1ceff17b0 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -12,6 +12,7 @@ vi.mock('node:dns/promises', () => ({ })) import { + app, BrowserWindow, dialog, session as electronSession, @@ -19,6 +20,11 @@ import { shell, WebContentsView, } from 'electron' +import { + appLaunchNoticeForContents, + handleBrowserLogin, + pagePromptForContents, +} from '@/main/browser-agent/page-prompts' import * as panel from '@/main/browser-agent/panel' import { agentAppOrigin, routeAgentNavigation } from '@/main/browser-agent/registry' import * as sessionModule from '@/main/browser-agent/session' @@ -32,7 +38,7 @@ function _setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } -type PopupHandler = (details: { url: string }) => { +type PopupHandler = (details: { url: string; disposition?: string }) => { action: string outlivesOpener?: boolean createWindow?: (options: Record) => unknown @@ -352,6 +358,13 @@ describe('browser-agent session', () => { session = freshSession(win) }) + it('ignores browser shortcuts and mouse buttons before any chat has opened a browser', () => { + session.disposeBrowserScope('chat-test') + + expect(session.handleFocusedShortcut('back', win)).toBe(false) + expect(session.handleFocusedShortcut('close-tab', win)).toBe(false) + }) + it('invalidates only top-frame starts and identifies same-document commits', () => { const onTabNavigated = vi.fn() session = freshSession(win, { onTabNavigated }) @@ -1250,6 +1263,11 @@ describe('browser-agent session', () => { contents.loadURL.mockClear() expect(openHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) expect(contents.loadURL).not.toHaveBeenCalled() + // window.open names no initiating frame, so an app link from it is never offered either. + session.enablePagePrompts() + app.getApplicationNameForProtocol = vi.fn(() => 'Zoom') + expect(openHandler({ url: 'zoommtg://zoom.us/join' })).toEqual({ action: 'deny' }) + expect(appLaunchNoticeForContents(contents as unknown as WebContents)).toBeUndefined() }) it('opens a background-disposition popup by URL and keeps cross-scheme popups denied', () => { @@ -1259,7 +1277,7 @@ describe('browser-agent session', () => { openWindow({ url: 'https://example.com/later' }).createWindow?.({}) const popup = (session.activeTab()?.view as unknown as MockView).webContents expect(popup).not.toBe(source) - expect(popup.loadURL).toHaveBeenCalledWith('https://example.com/later') + expect(popup.loadURL).toHaveBeenCalledWith('https://example.com/later', expect.anything()) expect(openWindow({ url: 'javascript:alert(1)' })).toEqual({ action: 'deny' }) expect(session.listTabs()).toHaveLength(2) }) @@ -1559,9 +1577,11 @@ describe('browser-agent session', () => { it('leaves nothing of the signed-out user behind in the browser profile', async () => { const clearStorageData = vi.fn(async () => {}) const clearCache = vi.fn(async () => {}) + const clearAuthCache = vi.fn(async () => {}) vi.mocked(electronSession.fromPartition).mockReturnValue({ clearStorageData, clearCache, + clearAuthCache, } as unknown as ReturnType) const { persistence, snapshots } = memoryBrowserPersistence() session = freshSession(win, {}, persistence) @@ -1580,6 +1600,8 @@ describe('browser-agent session', () => { expect(snapshots.get('chat-test')).toMatchObject({ tabs: [], activeIndex: -1 }) expect(clearStorageData).toHaveBeenCalled() expect(clearCache).toHaveBeenCalled() + // Chromium keeps typed HTTP sign-in credentials outside storage and cache. + expect(clearAuthCache).toHaveBeenCalled() }) it('hides the panel when the renderer stops renewing its bounds lease', async () => { @@ -1712,6 +1734,57 @@ describe('browser-agent session', () => { }) }) + it('asks about an HTTP sign-in only while its top-level navigation is still loading', () => { + const url = 'https://intranet.example/' + const contents = (session.ensureTab().view as unknown as MockView).webContents + session.enablePagePrompts() + const emit = (eventName: string, ...args: unknown[]) => { + for (const [name, handler] of contents.on.mock.calls) { + if (name === eventName && typeof handler === 'function') handler(...args) + } + } + const signIn = () => { + const callback = vi.fn() + handleBrowserLogin( + { preventDefault: () => {} }, + contents as unknown as WebContents, + { url, isRequestForNavigation: true, firstAuthAttempt: true } as never, + { isProxy: false, scheme: 'basic', host: 'intranet.example', port: 443, realm: '' }, + callback + ) + return callback + } + + emit('did-start-navigation', { isMainFrame: true, isSameDocument: false, url }) + const loading = signIn() + expect(loading).not.toHaveBeenCalled() + expect(pagePromptForContents(contents as unknown as WebContents)).toMatchObject({ + kind: 'http-auth', + }) + + // The navigation is aborted: Chromium reports ERR_ABORTED, which records no failure. + emit('did-start-navigation', { isMainFrame: true, isSameDocument: false, url }) + emit('did-stop-loading') + expect(signIn()).toHaveBeenCalledExactlyOnceWith() + }) + + it('counts a download as in flight for a restart only until it finishes', async () => { + const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-')) + session = freshSession(win, {}, undefined, { + getDirectory: () => directory, + getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER, + }) + const contents = (session.ensureTab().view as unknown as MockView).webContents + const download = mockDownloadItem({ filename: 'report.csv', totalBytes: 100 }) + + startMockDownload(contents, download) + await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce()) + expect(session.countProgressingBrowserDownloads()).toBe(1) + + download.emitDone('completed') + await vi.waitFor(() => expect(session.countProgressingBrowserDownloads()).toBe(0)) + }) + it('never overwrites a file that takes the allocated name before the download claims it', async () => { const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-')) writeFileSync(join(directory, 'taken.bin'), 'user data') @@ -2321,13 +2394,28 @@ describe('importAgentCookies', () => { describe('first-party browser sessions', () => { const origin = 'https://www.dev.sim.ai' + const appSession = new WebContentsView().webContents.session function initialize(persistence?: sessionModule.BrowserSessionPersistence) { return freshSession(null, {}, persistence, undefined, { origin, - session: new WebContentsView().webContents.session, + session: appSession, }) } + it("opens a deferred blank popup in its opener's session", () => { + const session = initialize() + const opener = session.addAutomationTab(`${origin}/home`).view.webContents + Object.assign(opener, { session: appSession }) + const openWindow = vi.mocked(opener.setWindowOpenHandler).mock.calls[0]?.[0] as PopupHandler + + const popup = openWindow({ url: 'about:blank', disposition: 'background-tab' }).createWindow?.( + {} + ) as WebContents + expect(popup).not.toBe(opener) + expect(agentAppOrigin(popup)).toBe(origin) + expect(popup.loadURL).not.toHaveBeenCalled() + }) + it('keeps a populated tab and its history when crossing the session boundary', () => { const session = initialize() const tab = session.addAutomationTab(`${origin}/home`) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 752fca5bec9..e912baa5722 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -34,6 +34,7 @@ import type { DownloadItem, Input, MenuItemConstructorOptions, + Referrer, Session, WebContents, WebContentsViewConstructorOptions, @@ -51,9 +52,17 @@ import { isDispatchingAgentInput } from '@/main/browser-agent/cdp' import { attachAgentContextMenu, BASE_ZOOM_FACTOR, + type PageNavigation, steppedZoomFactor, } from '@/main/browser-agent/context-menu' import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions' +import { + cancelPagePrompts, + handleSelectPasskeyAccount, + noteMainFrameNavigation, + resetPagePromptDecisions, + setPagePromptHost, +} from '@/main/browser-agent/page-prompts' import { activatePanelScope, detachIfAttached, @@ -80,10 +89,12 @@ import { import { clearHostVerdictCache } from '@/main/browser-agent/url-guard' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads' -import { isAppOrigin } from '@/main/navigation' +import { withinWindow } from '@/main/input-activity' +import { isAppOrigin, isBlankPopupUrl, parseHttpUrl } from '@/main/navigation' import { type FocusedResourceShortcut, isResourceTabSelectionShortcut, + type ResourceTabSelectionShortcut, resourceTabTargetIndex, zoomActionForShortcut, } from '@/main/resource-shortcuts' @@ -101,6 +112,7 @@ export interface BrowserAppSession { let browserAppSession: BrowserAppSession | undefined const configuredDownloadSessions = new WeakSet() +const configuredPasskeySessions = new WeakSet() const routedNavigations = new WeakMap() class SessionError extends Error {} @@ -118,8 +130,19 @@ export interface AgentTab { pendingMediaPermission?: PendingMediaPermission mediaPermissionGrant?: MediaPermissionGrant lastRealUserGestureAt?: number + /** + * Last real click or keypress in this tab. Unlike the media gesture, navigation keeps it: a + * click on an app link starts a navigation, clearing that one, before the launch request. + */ + lastUserActivationAt?: number + /** When the current document committed; activation before it belonged to another page. */ + documentCommittedAt?: number + /** Last click or keypress the agent dispatched; activation belongs to whoever acted last. */ + lastAgentInputAt?: number /** The tab whose page opened this one; agent work returns there when this tab closes. */ openerTabId?: string + /** Closing this tab while active shows its opener, until the user switches tabs (as in Chrome). */ + returnToOpenerOnClose?: boolean /** A user action asked for this page to take focus once it is on screen. */ pendingUserFocus?: boolean /** Electron owns the native alert or confirm until CDP reports it closed. */ @@ -190,6 +213,8 @@ export interface AgentSessionEvents { onTabThemeChanged: (contents: WebContents, theme: BrowserTheme) => void /** A download started, progressed, or finished inside one chat's browser. */ onDownloadsChanged?: (state: BrowserDownloadsState) => void + /** Something a page tried that the agent should learn from its next result in this chat. */ + onAgentNotice?: (notice: string) => void } /** @@ -221,6 +246,8 @@ const BACKGROUND_TAB_RESTORE_TIMEOUT_MS = 15_000 const FOREGROUND_TAB_RESTORE_TIMEOUT_MS = 20_000 const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000 const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000 +/** Chromium's transient user activation lifetime. */ +const USER_ACTIVATION_WINDOW_MS = 5_000 export type BrowserShortcut = | 'focus-omnibox' @@ -229,39 +256,79 @@ export type BrowserShortcut = | 'find' | 'reload' | 'hard-reload' + | UserOnlyShortcut + +type UserOnlyShortcut = 'back' | 'forward' | 'previous-tab' | 'next-tab' | 'stop' +/** Shortcuts that move the user's history, tabs, or load; keystrokes the agent sends never trigger them. */ +const USER_ONLY_SHORTCUTS: ReadonlySet = new Set([ + 'back', + 'forward', + 'previous-tab', + 'next-tab', + 'stop', +]) + +function isUserOnlyShortcut(shortcut: BrowserShortcut): shortcut is UserOnlyShortcut { + return USER_ONLY_SHORTCUTS.has(shortcut) +} type BrowserShortcutInput = Pick< Input, - 'type' | 'key' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta' + 'type' | 'key' | 'code' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta' > /** * Resolves browser-level shortcuts using Command on macOS and Control - * elsewhere. Alt, composing, and repeated keystrokes stay with the page. + * elsewhere, following Chrome's bindings. Composing and repeated keystrokes, + * and every other Alt chord, stay with the page. * * Reload resolves here, from the page's own keystroke, rather than through the * application menu: the menu can only guess which surface owns focus, and a * wrong guess reloads all of Sim instead of the page. + * + * `stop` is any bare Escape; only a loading page acts on it, and the page + * still receives the key. */ export function browserShortcutForInput( input: BrowserShortcutInput, platform: NodeJS.Platform = process.platform ): BrowserShortcut | null { - if (input.type !== 'keyDown' || input.isAutoRepeat || input.isComposing || input.alt) { - return null + if (input.type !== 'keyDown' || input.isAutoRepeat || input.isComposing) return null + const isMac = platform === 'darwin' + const primaryModifier = isMac ? input.meta : input.control + const otherModifier = isMac ? input.control : input.meta + + if (input.alt) { + if (input.shift || otherModifier) return null + const arrow = input.key === 'ArrowLeft' ? 'left' : input.key === 'ArrowRight' ? 'right' : null + if (!arrow) return null + // As in Chrome, these win even inside a text field. + if (isMac) { + if (!input.meta) return null + return arrow === 'left' ? 'previous-tab' : 'next-tab' + } + if (input.control) return null + return arrow === 'left' ? 'back' : 'forward' } - const primaryModifier = platform === 'darwin' ? input.meta : input.control - const otherModifier = platform === 'darwin' ? input.control : input.meta - + if (input.key === 'Escape' && !input.shift && !input.control && !input.meta) return 'stop' if (input.key === 'F5' && !otherModifier) { if (input.shift || input.control) return 'hard-reload' return input.meta ? null : 'reload' } if (!primaryModifier || otherModifier) return null + if (!isMac && !input.shift && (input.key === 'PageUp' || input.key === 'PageDown')) { + return input.key === 'PageUp' ? 'previous-tab' : 'next-tab' + } const key = input.key.toLowerCase() if (key === 'r') return input.shift ? 'hard-reload' : 'reload' - if (input.shift) return null + if (input.shift) { + if (!isMac) return null + // Physical key: Shift turns the bracket into a brace on most layouts. + if (input.code === 'BracketLeft') return 'previous-tab' + if (input.code === 'BracketRight') return 'next-tab' + return null + } switch (key) { case 'l': return 'focus-omnibox' @@ -298,6 +365,8 @@ interface BrowserScopeState { browserChromeFocused: boolean /** The renderer shows page dialogs, so the user can be asked instead of the shell answering. */ pageDialogsEnabled: boolean + /** The renderer shows sign-in and app-launch prompts; until then the shell refuses them. */ + pagePromptsEnabled: boolean automationActive: boolean automationNeedsAttention: boolean /** @@ -332,6 +401,7 @@ function createBrowserScopeState(): BrowserScopeState { focusedBrowserClearTimer: null, browserChromeFocused: false, pageDialogsEnabled: false, + pagePromptsEnabled: false, automationActive: false, automationNeedsAttention: false, findingTabId: null, @@ -572,6 +642,15 @@ function activeBrowserDownloadCount(scopeId?: string): number { return count } +/** Agent-browser downloads still receiving bytes, which a restart would cut off. */ +export function countProgressingBrowserDownloads(): number { + let count = 0 + for (const active of activeBrowserDownloads) { + if (active.download.state === 'progressing') count += 1 + } + return count +} + function withBrowserDownloadTimeout( operation: Promise, timeoutMs: number, @@ -1066,6 +1145,47 @@ export function initSession( getMainWindow = mainWindowProvider browserSessionPersistence = persistence ?? null browserDownloadSettings = downloadSettings ?? null + setPagePromptHost({ + tabContext: (contents) => { + const scoped = scopedTabForContents(contents) + if (!scoped) return null + const { scopeId, tab } = scoped + const onScreen = tabIsOnScreen(scopeId, tab) + const agentActedLast = + isDispatchingAgentInput(contents) || + (tab.lastAgentInputAt !== undefined && + tab.lastAgentInputAt >= (tab.lastUserActivationAt ?? 0) && + withinWindow(tab.lastAgentInputAt, USER_ACTIVATION_WINDOW_MS)) + const userAt = tab.lastUserActivationAt + return { + scopeId, + promptsEnabled: browserScopeStates.get(scopeId)?.pagePromptsEnabled === true, + onScreen, + userActivated: + onScreen && + userAt !== undefined && + userAt > Math.max(tab.documentCommittedAt ?? 0, tab.lastAgentInputAt ?? 0) && + withinWindow(userAt, USER_ACTIVATION_WINDOW_MS), + agentActedLast, + agentDrives: withBrowserScope(scopeId, () => agentOwnsPopupFrom(contents)), + } + }, + publish: (contents) => { + const scopeId = browserScopeIdForContents(contents) + if (scopeId) withBrowserScope(scopeId, () => events?.onPageStateChanged(contents)) + }, + focus: (contents) => { + const scoped = scopedTabForContents(contents) + const win = panelWindow() + if (scoped && tabIsOnScreen(scoped.scopeId, scoped.tab) && win && !win.isDestroyed()) { + win.webContents.focus() + } + }, + noticeForAgent: (contents, notice) => { + const scopeId = browserScopeIdForContents(contents) + if (scopeId) withBrowserScope(scopeId, () => events?.onAgentNotice?.(notice)) + }, + }) initPanel({ onGeometryChanged: () => events?.onPanelGeometryChanged?.(), getMainWindow: () => getMainWindow(), @@ -1457,16 +1577,22 @@ function scopedTabForContents(contents: WebContents): { scopeId: string; tab: Ag return tab ? { scopeId, tab } : null } -function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean { - const win = panelWindow() +function tabIsOnScreen(scopeId: string, tab: AgentTab): boolean { return ( resolveBrowserScopeId(scopeId) === getActiveBrowserScopeId() && browserScopeStates.get(scopeId)?.activeTabId === tab.id && - isPanelVisible() && + isPanelVisible() + ) +} + +function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean { + const win = panelWindow() + return ( + tabIsOnScreen(scopeId, tab) && Boolean(win && !win.isDestroyed() && win.isFocused()) && tab.view.webContents.isFocused() && typeof tab.lastRealUserGestureAt === 'number' && - Date.now() - tab.lastRealUserGestureAt <= MEDIA_PERMISSION_GESTURE_WINDOW_MS + withinWindow(tab.lastRealUserGestureAt, MEDIA_PERMISSION_GESTURE_WINDOW_MS) ) } @@ -1602,6 +1728,12 @@ const browserPermissions: BrowserPermissionHandlers = { withBrowserScope(scoped.scopeId, () => publishPageIssue(scoped.tab)) return } + // Blink asks only after a click or key, and the view keeps the window as it + // is; Electron never settles a refused request, blocking every later one. + if (permission === 'fullscreen') { + callback(Boolean(scopedTabForContents(contents))) + return + } callback(ALLOWED_SITE_PERMISSIONS.has(permission)) }, check: (contents, permission, requestingOrigin, details) => { @@ -1649,6 +1781,7 @@ function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) keepSessionCookiesAcrossRestarts(ses) + configureBrowserPasskeys(ses) ses.setPermissionRequestHandler(browserPermissions.request) ses.setPermissionCheckHandler(browserPermissions.check) ses.webRequest.onBeforeRequest((details, callback) => { @@ -1657,6 +1790,16 @@ function configureAgentPartition(ses: Session): void { configureBrowserDownloads(ses) } +/** + * Passkey account choice for browser tabs. The borrowed app session gets the + * listener too, so the handler cancels requests from any non-browser contents. + */ +function configureBrowserPasskeys(ses: Session): void { + if (configuredPasskeySessions.has(ses)) return + configuredPasskeySessions.add(ses) + ses.on('select-webauthn-account', handleSelectPasskeyAccount) +} + /** Borrowing app authentication must not replace its permission or request handlers. */ function configureBrowserDownloads(ses: Session): void { if (configuredDownloadSessions.has(ses)) return @@ -1891,6 +2034,11 @@ export function enablePageDialogs(): void { currentScope.pageDialogsEnabled = true } +/** Lets this scope's renderer show sign-in and app-launch prompts from now on. */ +export function enablePagePrompts(): void { + currentScope.pagePromptsEnabled = true +} + /** * Whether a dialog on this tab is the user's to answer: the renderer can show * it, the page is on screen, and it is the user's page rather than the agent's. @@ -2053,6 +2201,7 @@ export function recordPageLoadFailure( contents: WebContents, issue: Extract ): void { + noteMainFrameNavigation(contents, null) if ((issue.code === -2 || issue.code === -3) && routedNavigations.has(contents)) return const tab = tabForContents(contents) if (!tab) return @@ -2174,6 +2323,38 @@ function hardReloadPage(contents: WebContents): void { contents.reloadIgnoringCache() } +/** A browser-chrome history move or reload, held for the page's leave-site question. */ +function userNavigate(contents: WebContents, kind: PageNavigation): void { + navigateForUser(contents, () => + kind === 'back' + ? goBack(contents) + : kind === 'forward' + ? goForward(contents) + : reloadPage(contents) + ) +} + +/** Moves one tab's history, or selects a tab relative to it in the strip. */ +function runTabShortcut( + tab: AgentTab, + shortcut: 'back' | 'forward' | ResourceTabSelectionShortcut +): void { + if (shortcut === 'back' || shortcut === 'forward') { + userNavigate(tab.view.webContents, shortcut) + return + } + const targetIndex = resourceTabTargetIndex( + shortcut, + tabs.length, + tabs.findIndex((entry) => entry.id === tab.id) + ) + const target = targetIndex === null ? null : tabs[targetIndex] + if (target) { + switchTab(target.id) + target.view.webContents.focus() + } +} + /** Hands one page selection to the exact app window and chat hosting its tab. */ function addPageSelectionToChat(contents: WebContents, text: string): void { if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return @@ -2292,12 +2473,11 @@ export function stopFindInActiveTab(focusPage: boolean): void { if (tab) tab.view.webContents.focus() } -/** - * Whether a page's popup can become a tab that keeps its opener: http(s), within the tab - * limits, and in the opener's own session (a session boundary needs a separate, opener-less tab). - */ +/** Whether a popup can become an opener-keeping tab: blank (Chromium puts it in the opener's session) or same-session http(s), within the tab limits. */ function canAdoptPopup(opener: WebContents, url: string): boolean { - if (!/^https?:\/\//i.test(url) || !hasTabCapacity()) return false + if (!hasTabCapacity()) return false + if (isBlankPopupUrl(url)) return true + if (!/^https?:\/\//i.test(url)) return false return ( !browserAppSession || isAppOrigin(url, browserAppSession.origin) === Boolean(agentAppOrigin(opener)) @@ -2309,14 +2489,21 @@ function adoptPopupTab( opener: WebContents, url: string, popup: PopupWindowOptions, - { agentOwned, background }: { agentOwned: boolean; background: boolean } + { + agentOwned, + background, + referrer, + }: { agentOwned: boolean; background: boolean; referrer?: Referrer } ): WebContents { const openerTabId = tabForContents(opener)?.id - const tab = openLinkTab(url, { agentOwned, background }, popup) + const tab = openLinkTab(url, { agentOwned, background }, { opener, options: popup }) tab.openerTabId = openerTabId + tab.returnToOpenerOnClose = true const contents = tab.view.webContents // A background-tab disposition defers creation, so Chromium supplies no contents to adopt. - if (!popup.webContents) void contents.loadURL(url).catch(() => {}) + if (!popup.webContents && !isBlankPopupUrl(url)) { + void contents.loadURL(url, { httpReferrer: referrer }).catch(() => {}) + } return contents } @@ -2327,7 +2514,7 @@ function adoptPopupTab( function openLinkTab( url: string, { agentOwned, background }: { agentOwned: boolean; background: boolean }, - popup?: PopupWindowOptions + popup?: AdoptedPopup ): AgentTab { if (agentOwned) return addAutomationTab(url, popup) if (background) { @@ -2347,12 +2534,16 @@ function openLinkTab( */ function openTabWithUrl( url: string, - { agentOwned, background = false }: { agentOwned: boolean; background?: boolean } + { + agentOwned, + background = false, + referrer, + }: { agentOwned: boolean; background?: boolean; referrer?: Referrer } ): void { - if (!/^https?:\/\//i.test(url)) return + if (!parseHttpUrl(url)) return try { const tab = openLinkTab(url, { agentOwned, background }) - void tab.view.webContents.loadURL(url).catch(() => {}) + void tab.view.webContents.loadURL(url, { httpReferrer: referrer }).catch(() => {}) } catch (error) { logger.warn('Could not open a link in a new browser tab', { error: getErrorMessage(error), @@ -2366,18 +2557,33 @@ function openTabWithUrl( */ type PopupWindowOptions = BrowserWindowConstructorOptions & WebContentsViewConstructorOptions +/** A page-opened window becoming a tab, with the page that opened it. */ +interface AdoptedPopup { + opener: WebContents + options: PopupWindowOptions +} + /** * Creates a tab's view. `popup` adopts a page-opened window's WebContents, which Chromium created * with the opener's (identical) web preferences, so the opener relationship and its session stay - * intact; callers only adopt popups whose URL belongs to the opener's session. + * intact; a deferred popup without one gets a fresh view in the opener's session. Callers only + * adopt popups that are blank or whose URL belongs to the opener's session. */ -function createTabView(url?: string, popup?: PopupWindowOptions): WebContentsView { - const appSession = - url && browserAppSession && isAppOrigin(url, browserAppSession.origin) - ? browserAppSession - : undefined +function createTabView(url?: string, popup?: AdoptedPopup): WebContentsView { + let appSession: BrowserAppSession | undefined + if (popup) { + // A popup follows its opener's session (Chromium creates it there); a blank URL names no origin. + const popupSession = popup.options.webContents?.session ?? popup.opener.session + if (browserAppSession && popupSession === browserAppSession.session) { + appSession = browserAppSession + } + } else if (url && browserAppSession && isAppOrigin(url, browserAppSession.origin)) { + appSession = browserAppSession + } const scopeId = getBrowserScopeId() - const view = popup?.webContents ? new WebContentsView(popup) : createFreshTabView(appSession) + const view = popup?.options.webContents + ? new WebContentsView(popup.options) + : createFreshTabView(appSession) try { /** Detached tabs must lay out before a foreground panel owns their native view. */ const [width, height] = getMainWindow()?.getContentSize() ?? [1280, 720] @@ -2389,6 +2595,19 @@ function createTabView(url?: string, popup?: PopupWindowOptions): WebContentsVie } } +/** + * Preferences every tab needs, popups Chromium creates included. + * `focusOnNavigation`: Electron focuses a page on every main-frame commit, even + * a hidden agent tab, which steals the caret from chat; focus follows explicit + * user actions instead (see focusPageForUser). + * `disableHtmlFullscreenWindowResize`: a page's fullscreen fills the browser + * panel, never the Sim window. + */ +const AGENT_TAB_WEB_PREFERENCES = { + focusOnNavigation: false, + disableHtmlFullscreenWindowResize: true, +} as const + function createFreshTabView(appSession: BrowserAppSession | undefined): WebContentsView { return new WebContentsView({ webPreferences: { @@ -2409,11 +2628,9 @@ function createFreshTabView(appSession: BrowserAppSession | undefined): WebConte // the active tab while a tool waits on it, applied explicitly by // applyAutomationTabPolicy — never blanket across every tab. backgroundThrottling: true, - // Electron focuses a page on every main-frame commit, even a hidden - // agent tab, which steals the caret from chat. Focus follows explicit - // user actions instead (see focusPageForUser). - focusOnNavigation: false, - spellcheck: false, + ...AGENT_TAB_WEB_PREFERENCES, + // Off elsewhere: Chromium would download Hunspell dictionaries. + spellcheck: process.platform === 'darwin', // The default every origin this tab visits starts at; a per-origin zoom // the user sets from the page menu still wins and still persists. zoomFactor: getBrowserDefaultZoomFactor(), @@ -2429,8 +2646,10 @@ function initializeTabView( view.setBackgroundColor(browserBackgroundColor()) const contents = view.webContents registerAgentWebContents(contents, appOrigin, browserPermissions) - if (appOrigin) configureBrowserDownloads(contents.session) - else configureAgentPartition(contents.session) + if (appOrigin) { + configureBrowserDownloads(contents.session) + configureBrowserPasskeys(contents.session) + } else configureAgentPartition(contents.session) const routeNavigation = (url: string, method: string): boolean => { if (!/^https?:\/\//i.test(url) || !browserAppSession) return false const wantsAppSession = isAppOrigin(url, browserAppSession.origin) @@ -2457,10 +2676,27 @@ function initializeTabView( registerAgentNavigation(contents, routeNavigation) attachAgentContextMenu(contents, { addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)), - openTab: (url) => - withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false, background: true })), + openTab: (url, referrer) => + withBrowserScope(scopeId, () => + openTabWithUrl(url, { agentOwned: false, background: true, referrer }) + ), defaultZoomFactor: getBrowserDefaultZoomFactor, + history: () => + withBrowserScope(scopeId, () => ({ + canGoBack: canGoBack(contents), + canGoForward: canGoForward(contents), + })), + navigate: (kind) => withBrowserScope(scopeId, () => userNavigate(contents, kind)), }) + // Chromium leaves Control-wheel zoom to the embedder; step the page menu's ladder. + contents.on( + 'zoom-changed', + bindToBrowserScope(scopeId, (_event, direction) => { + contents.setZoomFactor( + steppedZoomFactor(contents.getZoomFactor(), direction === 'in' ? 1 : -1) + ) + }) + ) contents.on( 'focus', @@ -2507,17 +2743,19 @@ function initializeTabView( contents.on( 'before-mouse-event', bindToBrowserScope(scopeId, (_event, mouse) => { - if ( - isDispatchingAgentInput(contents) || - !['mouseDown', 'contextMenu', 'mouseWheel'].includes(mouse.type) - ) { + if (!['mouseDown', 'contextMenu', 'mouseWheel'].includes(mouse.type)) return + const tab = tabs.find((entry) => entry.view.webContents === contents) + if (isDispatchingAgentInput(contents)) { + if (tab && mouse.type === 'mouseDown') tab.lastAgentInputAt = Date.now() return } - const tab = tabs.find((entry) => entry.view.webContents === contents) if (tab && tab.id === currentScope.automationTabId) noteUserIntervention(tab.id) if (tab?.id === currentScope.activeTabId) { currentScope.visibleTabUserSelected = true - if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now() + if (mouse.type === 'mouseDown') { + tab.lastRealUserGestureAt = Date.now() + tab.lastUserActivationAt = tab.lastRealUserGestureAt + } } }) ) @@ -2544,27 +2782,34 @@ function initializeTabView( }) ) - // Keep popups inside the browser resource: http(s) window.open and - // target=_blank requests become a new internal tab, never a native window. - // A same-session popup adopts Chromium's own WebContents so window.opener - // survives (sign-in and "connect" popups post their result back to it). + // Popups become internal tabs, never native windows; adopting Chromium's own + // WebContents keeps window.opener for sign-in popups that post back to it. contents.setWindowOpenHandler((details) => withBrowserScope(scopeId, () => { const agentOwned = agentOwnsPopupFrom(contents) const background = details.disposition === 'background-tab' - if (!canAdoptPopup(contents, details.url)) { - openTabWithUrl(details.url, { agentOwned, background }) + const adopt = canAdoptPopup(contents, details.url) + // Only Chromium can carry a form POST with its initiator; never reissue one as a GET. + if (details.postBody && (!adopt || background)) { + logger.warn('Refused a form POST into a new browser tab') + return { action: 'deny' } + } + if (!adopt) { + openTabWithUrl(details.url, { agentOwned, background, referrer: details.referrer }) return { action: 'deny' } } return { action: 'allow', outlivesOpener: true, - // A popup's contents are created by Chromium from these preferences, not - // from createFreshTabView, so they must opt out of navigation focus here. - overrideBrowserWindowOptions: { webPreferences: { focusOnNavigation: false } }, + // Chromium creates a popup's contents from these, not from createFreshTabView. + overrideBrowserWindowOptions: { webPreferences: AGENT_TAB_WEB_PREFERENCES }, createWindow: (options) => withBrowserScope(scopeId, () => - adoptPopupTab(contents, details.url, options, { agentOwned, background }) + adoptPopupTab(contents, details.url, options, { + agentOwned, + background, + referrer: details.referrer, + }) ), } }) @@ -2595,6 +2840,7 @@ function initializeTabView( if (!tab) return tab.nativePageDialogOpen = false invalidatePageDialog(tab) + cancelPagePrompts(contents) if (tab.recoveringUnresponsive) { tab.recoveringUnresponsive = false contents.reload() @@ -2639,20 +2885,35 @@ function initializeTabView( 'before-input-event', bindToBrowserScope(scopeId, (event, input) => { const tab = tabs.find((entry) => entry.view === view) + const agentInput = isDispatchingAgentInput(contents) + if (agentInput && input.type === 'keyDown' && tab) tab.lastAgentInputAt = Date.now() if ( - !isDispatchingAgentInput(contents) && + !agentInput && input.type === 'keyDown' && tab && tab.id === currentScope.automationTabId ) { noteUserIntervention(tab.id) } - if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) { + if (!agentInput && tab?.id === currentScope.activeTabId) { currentScope.visibleTabUserSelected = true - if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now() + if (input.type === 'keyDown' && !input.isAutoRepeat) { + tab.lastRealUserGestureAt = Date.now() + tab.lastUserActivationAt = tab.lastRealUserGestureAt + } } const shortcut = browserShortcutForInput(input) if (!shortcut) return + if (isUserOnlyShortcut(shortcut)) { + if (agentInput || !tab) return + if (shortcut === 'stop') { + if (contents.isLoadingMainFrame()) contents.stop() + return + } + event.preventDefault() + runTabShortcut(tab, shortcut) + return + } event.preventDefault() if (shortcut === 'focus-omnibox') { @@ -2669,7 +2930,7 @@ function initializeTabView( return } if (shortcut === 'reload') { - navigateForUser(contents, () => reloadPage(contents)) + userNavigate(contents, 'reload') return } if (shortcut === 'hard-reload') { @@ -2721,14 +2982,30 @@ function initializeTabView( 'did-navigate', bindToBrowserScope(scopeId, () => { const tab = tabs.find((entry) => entry.view.webContents === contents) - if (tab) tab.pendingRestoreUrl = undefined + if (tab) { + tab.pendingRestoreUrl = undefined + tab.documentCommittedAt = Date.now() + } + noteMainFrameNavigation(contents, null) persistBrowserSession() }) ) + contents.on('did-redirect-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) + noteMainFrameNavigation(contents, details.url) + }) + // An aborted navigation (ERR_ABORTED) reports no failure the driver records; once nothing is + // loading, no top-level request is pending, so a later frame cannot pass for it. + contents.on('did-stop-loading', () => noteMainFrameNavigation(contents, null)) + contents.on( 'did-start-navigation', bindToBrowserScope(scopeId, (details) => { if (!details.isMainFrame) return + if (!details.isSameDocument) { + cancelPagePrompts(contents) + noteMainFrameNavigation(contents, details.url) + } const tab = tabForContents(contents) if (!tab) return invalidatePageDialog(tab) @@ -2773,6 +3050,7 @@ function initializeTabView( // the view's contents, so remove it without touching them. const tab = tabs.find((entry) => entry.view === view) if (tab) removeTab(tab, null) + cancelPagePrompts(contents) events?.onTabClosed(contents) }) ) @@ -2934,7 +3212,7 @@ interface AddTabOptions { activate?: boolean notify?: boolean url?: string - popup?: PopupWindowOptions + popup?: AdoptedPopup } function addTabInternal({ @@ -3415,7 +3693,7 @@ export function restoreBrowserSession(): void { } } -export function addTab(url?: string, popup?: PopupWindowOptions): AgentTab { +export function addTab(url?: string, popup?: AdoptedPopup): AgentTab { restoreBrowserSession() currentScope.visibleTabUserSelected = true return addTabInternal({ url, popup }) @@ -3426,7 +3704,7 @@ export function addTab(url?: string, popup?: PopupWindowOptions): AgentTab { * renderer's decision: every page is a resource tab there, and it shows the * agent's tab or badges it depending on what the user is doing. */ -export function addAutomationTab(url?: string, popup?: PopupWindowOptions): AgentTab { +export function addAutomationTab(url?: string, popup?: AdoptedPopup): AgentTab { restoreBrowserSession() const tab = addTabInternal({ activate: false, notify: false, url, popup }) currentScope.automationTabId = tab.id @@ -3500,6 +3778,7 @@ export function switchTab(tabId: string, { claim = true }: { claim?: boolean } = if (previousActiveTab && previousActiveTab.id !== tab.id) { revokeTabMediaPermissions(previousActiveTab, false) previousActiveTab.pendingUserFocus = false + for (const entry of tabs) entry.returnToOpenerOnClose = false } // The claim describes the page on screen, so a mirrored switch to another // page leaves that page unclaimed rather than inheriting the last one's. @@ -3594,8 +3873,10 @@ function removeTab( clearFocusedBrowserTab(tab.id) detachIfAttached(tab.view) if (contents && !contents.isDestroyed()) contents.close() + const opener = tabs.find((entry) => entry.id === tab.openerTabId) if (currentScope.activeTabId === tab.id) { - currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null + const returnTo = tab.returnToOpenerOnClose ? opener : undefined + currentScope.activeTabId = (returnTo ?? tabs[index] ?? tabs[index - 1])?.id ?? null layout() const active = activeTab() if (active) { @@ -3603,7 +3884,6 @@ function removeTab( } } if (currentScope.automationTabId === tab.id) { - const opener = tabs.find((entry) => entry.id === tab.openerTabId) currentScope.automationTabId = opener?.id ?? (adoptNeighborForAgent ? ((tabs[index] ?? tabs[index - 1])?.id ?? null) : null) applyAutomationTabPolicy() @@ -3659,6 +3939,8 @@ export function handleFocusedShortcut( shortcut: FocusedResourceShortcut, ownerWindow?: BrowserWindow | null ): boolean { + // Accelerators and mouse buttons arrive before any chat has opened a browser. + if (!getActiveBrowserScopeId()) return false // Tab-management accelerators belong to the visible Browser even after // focus moves into Sim chrome. Otherwise Cmd-T/L/Shift-T silently stop // behaving like browser shortcuts, and Cmd-W becomes especially unsafe. @@ -3672,17 +3954,8 @@ export function handleFocusedShortcut( (visibleBrowserShortcut ? visibleActiveTab(ownerWindow) : null) if (!shortcutTab) return false - if (isResourceTabSelectionShortcut(shortcut)) { - const targetIndex = resourceTabTargetIndex( - shortcut, - tabs.length, - tabs.findIndex((tab) => tab.id === shortcutTab.id) - ) - const target = targetIndex === null ? null : tabs[targetIndex] - if (target) { - switchTab(target.id) - target.view.webContents.focus() - } + if (isResourceTabSelectionShortcut(shortcut) || shortcut === 'back' || shortcut === 'forward') { + runTabShortcut(shortcutTab, shortcut) return true } @@ -3702,26 +3975,14 @@ export function handleFocusedShortcut( case 'focus-omnibox': focusRendererOmnibox('select') return true - case 'reload-or-clear': { - const contents = shortcutTab.view.webContents - navigateForUser(contents, () => reloadPage(contents)) + case 'reload-or-clear': + userNavigate(shortcutTab.view.webContents, 'reload') return true - } case 'hard-reload': { const contents = shortcutTab.view.webContents navigateForUser(contents, () => hardReloadPage(contents)) return true } - case 'back': { - const contents = shortcutTab.view.webContents - navigateForUser(contents, () => goBack(contents)) - return true - } - case 'forward': { - const contents = shortcutTab.view.webContents - navigateForUser(contents, () => goForward(contents)) - return true - } } const zoomAction = zoomActionForShortcut(shortcut) @@ -3885,6 +4146,17 @@ export async function clearProfileStorage(): Promise { // allowlist would silently miss whatever Chromium adds next. await ses.clearStorageData() await ses.clearCache() + await clearBrowserSignIns(ses) +} + +/** + * Sign-out forgets every HTTP credential Chromium cached for browser tabs, in the + * borrowed app session too, and the remembered certificate and app-launch choices. + */ +async function clearBrowserSignIns(agentSession: Session): Promise { + resetPagePromptDecisions() + await agentSession.clearAuthCache() + await browserAppSession?.session.clearAuthCache() } /** @@ -3918,6 +4190,10 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise if (storages.length > 0) { await ses.clearStorageData({ storages } as Parameters[0]) } + if (kinds.includes('cookies')) { + await ses.clearAuthCache() + resetPagePromptDecisions(ses) + } if (kinds.includes('cache')) { await ses.clearCache() // Resolved-host verdicts are a cache too, and a user clearing the cache diff --git a/apps/desktop/src/main/browser-import/browser-launch.ts b/apps/desktop/src/main/browser-import/browser-launch.ts new file mode 100644 index 00000000000..120a4332de9 --- /dev/null +++ b/apps/desktop/src/main/browser-import/browser-launch.ts @@ -0,0 +1,25 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +const OPEN_TIMEOUT_MS = 15_000 + +/** + * Opens `url` in one profile of an installed Chromium-family browser. + * + * `-n` launches a fresh instance whose command line Chromium's process + * singleton forwards to the running browser; without it a running browser + * drops `--args` and opens the page in whichever profile was last active. + */ +export async function openInBrowserProfile( + bundleId: string, + profileDirectory: string, + url: string +): Promise { + await execFileAsync( + '/usr/bin/open', + ['-n', '-b', bundleId, '--args', `--profile-directory=${profileDirectory}`, url], + { timeout: OPEN_TIMEOUT_MS } + ) +} diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts index 7d65b37ca99..fa4c100f5bf 100644 --- a/apps/desktop/src/main/browser-import/browser-sources.ts +++ b/apps/desktop/src/main/browser-import/browser-sources.ts @@ -26,6 +26,8 @@ export interface BrowserSource { userDataSegments: readonly string[] /** The login-Keychain generic-password item holding the Safe Storage key. */ keychain: { service: string; account: string } + /** The macOS bundle identifier, used to open a page in this browser. */ + bundleId: string } export const BROWSER_SOURCES: readonly BrowserSource[] = [ @@ -34,42 +36,49 @@ export const BROWSER_SOURCES: readonly BrowserSource[] = [ label: 'Chrome', userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'], keychain: { service: 'Chrome Safe Storage', account: 'Chrome' }, + bundleId: 'com.google.Chrome', }, { id: 'arc', label: 'Arc', userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'], keychain: { service: 'Arc Safe Storage', account: 'Arc' }, + bundleId: 'company.thebrowser.Browser', }, { id: 'dia', label: 'Dia', userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'], keychain: { service: 'Dia Safe Storage', account: 'Dia' }, + bundleId: 'company.thebrowser.dia', }, { id: 'brave', label: 'Brave', userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'], keychain: { service: 'Brave Safe Storage', account: 'Brave' }, + bundleId: 'com.brave.Browser', }, { id: 'edge', label: 'Microsoft Edge', userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'], keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' }, + bundleId: 'com.microsoft.edgemac', }, { id: 'vivaldi', label: 'Vivaldi', userDataSegments: ['Library', 'Application Support', 'Vivaldi'], keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' }, + bundleId: 'com.vivaldi.Vivaldi', }, { id: 'chromium', label: 'Chromium', userDataSegments: ['Library', 'Application Support', 'Chromium'], keychain: { service: 'Chromium Safe Storage', account: 'Chromium' }, + bundleId: 'org.chromium.Chromium', }, ] as const diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts index f3db3020989..b64db8c100d 100644 --- a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts +++ b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts @@ -123,7 +123,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { }, ]) - const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + const result = await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS }) expect(result.rowsSeen).toBe(1) expect(result.cookies).toHaveLength(1) @@ -150,7 +150,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { }, ]) - const [cookie] = (await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies + const [cookie] = (await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).cookies expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 86_400, 3) }) @@ -163,7 +163,9 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { { hostKey: '.example.com', name: 'a', encryptedValue: encryptV10(plaintext) }, ]) - expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe('bound') + expect( + (await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).cookies[0].value + ).toBe('bound') }) it('counts unreadable and expired rows instead of failing the import', async () => { @@ -178,7 +180,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { }, ]) - const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + const result = await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS }) expect(result.rowsSeen).toBe(3) expect(result.cookies.map(({ name }) => name)).toEqual(['good']) @@ -186,6 +188,37 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { expect(result.skipped.expired).toBe(1) }) + it('reads only in-scope rows, never decrypting or counting another site', async () => { + const path = await writeCookieDatabase([ + { hostKey: '.example.com', name: 'domain', encryptedValue: encryptV10(Buffer.from('a')) }, + { + hostKey: 'Console.Example.com', + name: 'host', + encryptedValue: encryptV10(Buffer.from('b')), + }, + { + hostKey: 'notexample.com', + name: 'lookalike', + encryptedValue: encryptV10(Buffer.from('c')), + }, + { + hostKey: 'example.com.evil.com', + name: 'prefix', + encryptedValue: encryptV10(Buffer.from('d')), + }, + { hostKey: 'evil.com', name: 'broken', encryptedValue: Buffer.from('v20-not-supported') }, + ]) + + const result = await readBrowserCookies(path, KEY, { + nowSeconds: NOW_SECONDS, + scope: { domain: 'example.com', exact: false }, + }) + + expect(result.cookies.map(({ name }) => name).sort()).toEqual(['domain', 'host']) + expect(result.rowsSeen).toBe(2) + expect(result.skipped['decrypt-failed']).toBe(0) + }) + it('reports an unrecognised schema rather than guessing', async () => { const path = join(directory, 'Cookies') const { DatabaseSync } = await import('node:sqlite') @@ -193,7 +226,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { database.exec('CREATE TABLE something_else (a TEXT)') database.close() - await expect(readBrowserCookies(path, KEY, NOW_SECONDS)).rejects.toMatchObject({ + await expect(readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).rejects.toMatchObject({ code: 'unsupported-schema', }) }) @@ -204,7 +237,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { ]) const before = await stat(path) - await readBrowserCookies(path, KEY, NOW_SECONDS) + await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS }) const after = await stat(path) expect(after.size).toBe(before.size) @@ -217,7 +250,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) }, ]) - await readBrowserCookies(path, KEY, NOW_SECONDS) + await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS }) expect(await stagingLeftBehindSince(before)).toEqual([]) }) @@ -226,7 +259,9 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { const before = await stagingDirectories() await writeFile(join(directory, 'Cookies'), 'not a database') - await expect(readBrowserCookies(join(directory, 'Cookies'), KEY, NOW_SECONDS)).rejects.toThrow() + await expect( + readBrowserCookies(join(directory, 'Cookies'), KEY, { nowSeconds: NOW_SECONDS }) + ).rejects.toThrow() expect(await stagingLeftBehindSince(before)).toEqual([]) }) diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.ts b/apps/desktop/src/main/browser-import/chromium-cookies.ts index 74e79b6cd0b..18e74676f57 100644 --- a/apps/desktop/src/main/browser-import/chromium-cookies.ts +++ b/apps/desktop/src/main/browser-import/chromium-cookies.ts @@ -1,5 +1,6 @@ import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto' import { translateCookieRow } from '@/main/browser-import/cookie-translate' +import { isCookieHostInScope, type SiteScope } from '@/main/browser-import/site-scope' import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' import { type ChromiumCookieRow, @@ -17,12 +18,35 @@ import { const MAX_COOKIE_ROWS = 50_000 -const COOKIE_QUERY = ` +const COOKIE_COLUMNS = ` SELECT host_key, name, path, expires_utc, is_secure, is_httponly, has_expires, is_persistent, samesite, encrypted_value, value - FROM cookies - LIMIT ${MAX_COOKIE_ROWS} -` + FROM cookies` + +/** `%`, `_`, and the escape itself are LIKE metacharacters; a hostname may carry `_`. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + +/** + * The cookie query, narrowed in SQL to a site's rows when there is a scope. + * The narrowing is only an efficiency: {@link isCookieHostInScope} still + * decides every row, since LIKE is case-insensitive and knows no labels. + */ +function cookieQuery(scope: SiteScope | undefined): { query: string; params: string[] } { + if (!scope) return { query: `${COOKIE_COLUMNS} LIMIT ${MAX_COOKIE_ROWS}`, params: [] } + const exactHosts = [scope.domain, `.${scope.domain}`] + if (scope.exact) { + return { + query: `${COOKIE_COLUMNS} WHERE host_key IN (?, ?) LIMIT ${MAX_COOKIE_ROWS}`, + params: exactHosts, + } + } + return { + query: `${COOKIE_COLUMNS} WHERE host_key IN (?, ?) OR host_key LIKE ? ESCAPE '\\' LIMIT ${MAX_COOKIE_ROWS}`, + params: [...exactHosts, `%.${escapeLike(scope.domain)}`], + } +} export interface ReadCookiesResult { cookies: ImportableCookie[] @@ -45,6 +69,12 @@ function toRow(raw: Record): ChromiumCookieRow { } } +interface ReadCookiesOptions { + nowSeconds?: number + /** Limits the read to one site; other rows are never decrypted, returned, or counted. */ + scope?: SiteScope +} + /** * Rows that fail to decrypt or cannot be aimed at a single host are counted, * not thrown — one unreadable cookie should not cost the user the other @@ -53,14 +83,18 @@ function toRow(raw: Record): ChromiumCookieRow { export async function readBrowserCookies( cookiesPath: string, key: Buffer, - nowSeconds: number = Date.now() / 1000 + { nowSeconds = Date.now() / 1000, scope }: ReadCookiesOptions = {} ): Promise { - const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', COOKIE_QUERY) + const { query, params } = cookieQuery(scope) + const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', query, params) const cookies: ImportableCookie[] = [] const skipped = emptySkipCounts() + let rowsSeen = 0 for (const raw of rows) { const row = toRow(raw) + if (scope && !isCookieHostInScope(row.hostKey, scope)) continue + rowsSeen += 1 const encrypted = raw.encrypted_value let value: string | null if (encrypted instanceof Uint8Array && encrypted.length > 0) { @@ -82,5 +116,5 @@ export async function readBrowserCookies( } } - return { cookies, skipped, rowsSeen: rows.length } + return { cookies, skipped, rowsSeen } } diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts index cdede9365da..d21af03eb61 100644 --- a/apps/desktop/src/main/browser-import/import-service.test.ts +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -530,3 +530,23 @@ describe('remembering the sites an import brought over', () => { expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['example.com'])) }) }) + +describe('importChromeCookies with a site scope', () => { + it('reads only the scoped site from the chosen profile', async () => { + const readCookies = vi.fn(async () => read()) + const scope = { domain: 'amazon.com', exact: false } + + await expect( + importChromeCookies('arc:Profile 2', createDeps({ readCookies }), scope) + ).resolves.toEqual({ cookiesImported: 1, cookiesSkipped: 0 }) + expect(readCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer), scope) + }) + + it('reports nothing found, not a failure, when the profile has no sign-in for the site', async () => { + const deps = createDeps({ readCookies: async () => read({ cookies: [], rowsSeen: 0 }) }) + + await expect( + importChromeCookies('chrome:Default', deps, { domain: 'example.com', exact: false }) + ).resolves.toEqual({ cookiesImported: 0, cookiesSkipped: 0 }) + }) +}) diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts index 8c704361d16..e7ebaca621f 100644 --- a/apps/desktop/src/main/browser-import/import-service.ts +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -12,6 +12,7 @@ import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies' import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords' import type { ImportedSite } from '@/main/browser-import/chromium-site-names' +import type { SiteScope } from '@/main/browser-import/site-scope' import { type BrowserProfile, type ImportableCookie, @@ -34,7 +35,8 @@ export interface ImportServiceDeps { listProfiles: () => Promise /** Reads one browser's Safe Storage key; the item comes from its source. */ readSafeStoragePassword: (item: { service: string; account: string }) => Promise - readCookies: (cookiesPath: string, key: Buffer) => Promise + /** `scope` limits the read to one site; absent for a whole profile. */ + readCookies: (cookiesPath: string, key: Buffer, scope?: SiteScope) => Promise writeCookies: (cookies: ImportableCookie[]) => Promise<{ imported: number; failed: number }> readPasswords: (loginDataPath: string, key: Buffer) => Promise /** Site icons for the given origins, read from the source browser's own store. */ @@ -54,7 +56,7 @@ export interface ImportServiceDeps { } } -function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult { +export function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult { return { cookiesImported: 0, cookiesSkipped: 0, error } } @@ -152,7 +154,8 @@ export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProf } /** - * Copies one Chrome profile's cookies into the built-in browser. + * Copies one Chrome profile's cookies into the built-in browser — or, given a + * `scope`, only one site's: the sign-in the user just finished there. * * Fails closed at every step: an unsupported platform, an absent Chrome, a * refused Keychain prompt, or an unrecognised database all stop the import @@ -161,7 +164,8 @@ export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProf */ export async function importChromeCookies( profileId: string | undefined, - deps: ImportServiceDeps + deps: ImportServiceDeps, + scope?: SiteScope ): Promise { if (deps.platform !== 'darwin') return cookieFailure('unsupported-platform') @@ -169,7 +173,7 @@ export async function importChromeCookies( const open = await openBrowserProfile(profileId, deps) let outcome: CookieOutcome try { - outcome = await runCookieImport(open, deps) + outcome = await runCookieImport(open, deps, scope) } finally { zeroKey(open) } @@ -199,13 +203,14 @@ interface PasswordOutcome { */ async function runCookieImport( { profile, key }: OpenProfile, - deps: ImportServiceDeps + deps: ImportServiceDeps, + scope?: SiteScope ): Promise { if (profile.cookiesPath === null) { return { result: cookieFailure('profile-unreadable'), domains: new Set() } } - const read = await deps.readCookies(profile.cookiesPath, key) + const read = await deps.readCookies(profile.cookiesPath, key, scope) const skippedReading = totalSkipped(read.skipped) // Rows present but nothing usable means the profile did not decrypt — // typically a scheme this importer does not support. Report it as a diff --git a/apps/desktop/src/main/browser-import/index.test.ts b/apps/desktop/src/main/browser-import/index.test.ts new file mode 100644 index 00000000000..56cef1e287b --- /dev/null +++ b/apps/desktop/src/main/browser-import/index.test.ts @@ -0,0 +1,270 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources' +import type { BrowserProfile } from '@/main/browser-import/types' +import { emptySkipCounts } from '@/main/browser-import/types' + +/** + * Continuing a sign-in elsewhere and bringing it back, against stand-ins for + * the only things a test must never touch: the developer's real browser + * profiles, their Keychain, the browser launcher, and the Electron cookie jar. + */ +vi.mock('electron', () => import('@/test/electron-mock')) +vi.mock('@/main/browser-agent/session', () => ({ + importAgentCookies: vi.fn(async (cookies: unknown[]) => ({ + imported: cookies.length, + failed: 0, + })), +})) +vi.mock('@/main/browser-credentials', () => ({ + credentialsAvailable: () => false, + importCredentials: vi.fn(), +})) +vi.mock('@/main/browser-sites', () => ({ rememberSites: vi.fn(async () => {}) })) +vi.mock('@/main/account-data-generation', () => ({ + captureAccountDataGeneration: () => 0, + runAccountDataMutation: (_generation: number, operation: () => Promise) => operation(), +})) +vi.mock('@/main/browser-import/browser-launch', () => ({ openInBrowserProfile: vi.fn() })) +vi.mock('@/main/browser-import/chromium-profiles', () => ({ listAllBrowserProfiles: vi.fn() })) +vi.mock('@/main/browser-import/chromium-cookies', () => ({ readBrowserCookies: vi.fn() })) +vi.mock('@/main/browser-import/chromium-crypto', async (importOriginal) => ({ + ...(await importOriginal()), + readSafeStoragePassword: vi.fn(async () => 'safe-storage-password'), +})) + +const { openInBrowserProfile } = await import('@/main/browser-import/browser-launch') +const { listAllBrowserProfiles } = await import('@/main/browser-import/chromium-profiles') +const { readBrowserCookies } = await import('@/main/browser-import/chromium-cookies') +const { + bringSignInBack, + cancelSignIn, + clearSignIns, + continueSignIn, + getPendingSignIn, + migrateSignIn, +} = await import('@/main/browser-import') + +/** The importer is macOS-only; pin the platform so the suite means the same on every runner. */ +const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin' })) +afterAll(() => { + if (realPlatform) Object.defineProperty(process, 'platform', realPlatform) +}) + +/** Holds the next browser launch open until the test releases it. */ +function holdLaunch(): () => void { + let release: (() => void) | undefined + vi.mocked(openInBrowserProfile).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + return () => release?.() +} + +const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource +const PROFILE: BrowserProfile = { + id: 'arc:Profile 2', + directory: 'Profile 2', + label: 'Work', + source: ARC, + cookiesPath: '/arc/Profile 2/Cookies', + loginDataPaths: [], + faviconsPath: null, + historyPath: null, +} +const SIGN_IN_URL = + 'https://signin.aws.amazon.com/oauth?redirect_uri=https%3A%2F%2Fconsole.aws.amazon.com' +const request = (url = SIGN_IN_URL, profileId = PROFILE.id) => ({ profileId, tabId: 'tab-1', url }) + +function readsCookies(count: number) { + vi.mocked(readBrowserCookies).mockResolvedValue({ + cookies: Array.from({ length: count }, (_, index) => ({ + url: 'https://console.aws.amazon.com/', + name: `session-${index}`, + value: 'value', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' as const, + })), + skipped: emptySkipCounts(), + rowsSeen: count, + }) +} + +beforeEach(() => { + vi.mocked(listAllBrowserProfiles).mockResolvedValue([PROFILE]) + vi.mocked(openInBrowserProfile).mockResolvedValue() + readsCookies(2) + clearSignIns() +}) + +describe('continueSignIn', () => { + it('opens the exact page in the chosen profile and remembers it for the chat', async () => { + await expect(continueSignIn('chat-a', request())).resolves.toEqual({ + pending: { site: 'amazon.com', browserLabel: 'Arc' }, + }) + expect(openInBrowserProfile).toHaveBeenCalledWith( + 'company.thebrowser.Browser', + 'Profile 2', + SIGN_IN_URL + ) + expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' }) + expect(getPendingSignIn('chat-b')).toBeNull() + }) + + it.each([ + ['plain http', 'http://example.com/login'], + ['a local file', 'file:///etc/passwd'], + ['script', 'javascript:alert(1)'], + ['embedded credentials', 'https://user:secret@example.com/'], + ['garbage', 'not a url'], + ])('refuses %s without launching anything', async (_label, url) => { + await expect(continueSignIn('chat-a', request(url))).resolves.toEqual({ + error: 'unsupported-page', + }) + expect(openInBrowserProfile).not.toHaveBeenCalled() + }) + + it('refuses a profile it cannot bring a sign-in back from', async () => { + await expect( + continueSignIn('chat-a', request(SIGN_IN_URL, 'chrome:Profile 9')) + ).resolves.toEqual({ error: 'chrome-not-found' }) + vi.mocked(listAllBrowserProfiles).mockResolvedValue([{ ...PROFILE, cookiesPath: null }]) + await expect(continueSignIn('chat-a', request())).resolves.toEqual({ + error: 'chrome-not-found', + }) + expect(openInBrowserProfile).not.toHaveBeenCalled() + }) + + it('remembers nothing when the browser will not launch', async () => { + vi.mocked(openInBrowserProfile).mockRejectedValue(new Error('Unable to find application')) + + await expect(continueSignIn('chat-a', request())).resolves.toEqual({ error: 'launch-failed' }) + expect(getPendingSignIn('chat-a')).toBeNull() + }) +}) + +describe('bringSignInBack', () => { + it('imports only the handed-off site from that profile, without rescanning profiles', async () => { + await continueSignIn('chat-a', request()) + + await expect(bringSignInBack('chat-a')).resolves.toEqual({ + result: { cookiesImported: 2, cookiesSkipped: 0 }, + handoff: expect.objectContaining({ tabId: 'tab-1', url: SIGN_IN_URL }), + current: true, + }) + expect(readBrowserCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer), { + scope: { domain: 'amazon.com', exact: false }, + }) + expect(listAllBrowserProfiles).toHaveBeenCalledOnce() + expect(getPendingSignIn('chat-a')).toBeNull() + }) + + it('keeps the handoff pending when nothing came back, so the user can retry', async () => { + await continueSignIn('chat-a', request()) + readsCookies(0) + + await expect(bringSignInBack('chat-a')).resolves.toMatchObject({ + result: { cookiesImported: 0, cookiesSkipped: 0 }, + }) + expect(getPendingSignIn('chat-a')).not.toBeNull() + }) + + it('keeps a newer handoff that replaced the one being brought back', async () => { + await continueSignIn('chat-a', request()) + const signedIn = await vi.mocked(readBrowserCookies).getMockImplementation()?.( + '', + Buffer.alloc(0) + ) + let finishRead: (() => void) | undefined + vi.mocked(readBrowserCookies).mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = () => signedIn && resolve(signedIn) + }) + ) + const broughtBack = bringSignInBack('chat-a') + await vi.waitFor(() => expect(finishRead).toBeTypeOf('function')) + await continueSignIn('chat-a', request('https://accounts.google.com/signin')) + finishRead?.() + await expect(broughtBack).resolves.toMatchObject({ + result: { cookiesImported: 2 }, + current: false, + }) + + expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' }) + }) + + it('answers only to the chat that started the handoff', async () => { + await continueSignIn('chat-a', request()) + + await expect(bringSignInBack('chat-b')).resolves.toEqual({ + result: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + }) + expect(readBrowserCookies).not.toHaveBeenCalled() + }) + + it('has nothing to bring back once the user cancels', async () => { + await continueSignIn('chat-a', request()) + cancelSignIn('chat-a') + + await expect(bringSignInBack('chat-a')).resolves.toMatchObject({ result: { error: 'unknown' } }) + expect(readBrowserCookies).not.toHaveBeenCalled() + }) +}) + +describe('a handoff retired or moved while its browser launches', () => { + it('records nothing once a cancel lands mid-launch', async () => { + const release = holdLaunch() + const continuing = continueSignIn('chat-a', request()) + await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled()) + cancelSignIn('chat-a') + release() + + await expect(continuing).resolves.toEqual({}) + expect(getPendingSignIn('chat-a')).toBeNull() + }) + + it('lets a newer continue win over an older launch that finishes later', async () => { + const release = holdLaunch() + const older = continueSignIn('chat-a', request()) + await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled()) + await continueSignIn('chat-a', request('https://accounts.google.com/signin')) + release() + + await expect(older).resolves.toEqual({}) + expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' }) + }) + + it('follows a provisional chat to its saved id, mid-launch or after', async () => { + const release = holdLaunch() + const continuing = continueSignIn('pending:new-chat', request()) + await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled()) + migrateSignIn('pending:new-chat', 'chat-a') + release() + await continuing + + expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' }) + expect(getPendingSignIn('pending:new-chat')).toBeNull() + migrateSignIn('chat-a', 'chat-b') + await expect(bringSignInBack('chat-b')).resolves.toMatchObject({ + result: { cookiesImported: 2 }, + }) + }) + + it('forgets every handoff on account teardown, including one still launching', async () => { + await continueSignIn('chat-a', request()) + const release = holdLaunch() + const continuing = continueSignIn('chat-b', request()) + await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalledTimes(2)) + clearSignIns() + release() + + await expect(continuing).resolves.toEqual({}) + expect(getPendingSignIn('chat-a')).toBeNull() + expect(getPendingSignIn('chat-b')).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts index 299de7bcceb..d4140f32fe1 100644 --- a/apps/desktop/src/main/browser-import/index.ts +++ b/apps/desktop/src/main/browser-import/index.ts @@ -1,9 +1,11 @@ import type { BrowserChromeImportResult, + BrowserContinueSignInResult, BrowserCredentialConflictPolicy, BrowserImportProfile, BrowserImportResult, BrowserPasswordImportResult, + BrowserPendingSignIn, } from '@sim/desktop-bridge' import { captureAccountDataGeneration, @@ -11,6 +13,7 @@ import { } from '@/main/account-data-generation' import { importAgentCookies } from '@/main/browser-agent/session' import { credentialsAvailable, importCredentials } from '@/main/browser-credentials' +import { openInBrowserProfile } from '@/main/browser-import/browser-launch' import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto' import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons' @@ -18,13 +21,17 @@ import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords' import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles' import { readBrowserSites } from '@/main/browser-import/chromium-site-names' import { + cookieFailure, type ImportServiceDeps, listImportableProfiles, importChromeCookies as runCookieImport, importChromeData as runDataImport, importChromePasswords as runPasswordImport, } from '@/main/browser-import/import-service' +import { type SiteScope, siteScopeFor } from '@/main/browser-import/site-scope' +import type { BrowserProfile } from '@/main/browser-import/types' import { rememberSites } from '@/main/browser-sites' +import { isSafeExternalUrl } from '@/main/navigation' /** * Composition root for the Chrome importer: binds the real Keychain, profile, @@ -37,7 +44,7 @@ function deps(): ImportServiceDeps { platform: process.platform, listProfiles: () => listAllBrowserProfiles(), readSafeStoragePassword: (item) => readSafeStoragePassword(item), - readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key), + readCookies: (cookiesPath, key, scope) => readBrowserCookies(cookiesPath, key, { scope }), writeCookies: (cookies) => importAgentCookies(cookies), readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key), readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins), @@ -72,3 +79,120 @@ export function importChromeData( ): Promise { return runDataImport(profileId, policy, deps()) } + +/** A page the user took to another browser to finish signing in. */ +interface SignInHandoff { + /** Resolved once, so bringing the sign-in back reads exactly this profile. */ + profile: BrowserProfile + tabId: string + url: string + scope: SiteScope +} + +/** + * A chat's handoff, created before the browser launches so a cancel, a newer + * continue, sign-out, or the chat's id migrating during the launch can retire + * or move it. A launch records its handoff only while its slot is still current. + */ +interface HandoffSlot { + handoff?: SignInHandoff +} + +/** At most one per chat: continuing another page replaces the previous slot. */ +const slotsByScope = new Map() + +function pendingSignIn({ profile, scope }: SignInHandoff): BrowserPendingSignIn { + return { site: scope.domain, browserLabel: profile.source.label } +} + +function isCurrentSlot(slot: HandoffSlot): boolean { + for (const current of slotsByScope.values()) if (current === slot) return true + return false +} + +/** + * Opens a built-in browser page in one of the user's own browser profiles and + * remembers it for the chat, so {@link bringSignInBack} imports the site the + * user left from and reopens that page, whatever the tab shows by then. + * + * Only an https page goes, and only to a profile the importer can read back + * from: a sign-in that can never return would strand the user. A handoff + * retired while its browser launched resolves with neither `pending` nor + * `error`. + */ +export async function continueSignIn( + scopeId: string, + request: { profileId: string; tabId: string; url: string } +): Promise { + const scope = isSafeExternalUrl(request.url) ? siteScopeFor(new URL(request.url).hostname) : null + if (!scope) return { error: 'unsupported-page' } + const slot: HandoffSlot = {} + slotsByScope.set(scopeId, slot) + const profiles = await listAllBrowserProfiles().catch(() => []) + const profile = profiles.find(({ id }) => id === request.profileId) + if (!profile?.cookiesPath) return retire(slot, { error: 'chrome-not-found' }) + try { + await openInBrowserProfile(profile.source.bundleId, profile.directory, request.url) + } catch { + return retire(slot, { error: 'launch-failed' }) + } + if (!isCurrentSlot(slot)) return {} + slot.handoff = { profile, tabId: request.tabId, url: request.url, scope } + return { pending: pendingSignIn(slot.handoff) } +} + +function retire( + slot: HandoffSlot, + result: BrowserContinueSignInResult +): BrowserContinueSignInResult { + for (const [scopeId, current] of slotsByScope) { + if (current === slot) slotsByScope.delete(scopeId) + } + return result +} + +export function getPendingSignIn(scopeId: string): BrowserPendingSignIn | null { + const handoff = slotsByScope.get(scopeId)?.handoff + return handoff ? pendingSignIn(handoff) : null +} + +export function cancelSignIn(scopeId: string): void { + slotsByScope.delete(scopeId) +} + +/** Follows a provisional chat to its saved id, including a launch still in flight. */ +export function migrateSignIn(fromScopeId: string, toScopeId: string): void { + const slot = slotsByScope.get(fromScopeId) + if (!slot) return + slotsByScope.delete(fromScopeId) + slotsByScope.set(toScopeId, slot) +} + +/** Account teardown: forgets every handoff and retires launches still in flight. */ +export function clearSignIns(): void { + slotsByScope.clear() +} + +/** + * Imports the chat's handed-off site from the profile it was continued in. The + * handoff stays pending until something comes back, so the user can finish + * signing in and try again. `current` is false when a cancel, a newer + * continue, or teardown retired the handoff while it was being imported. + */ +export async function bringSignInBack(scopeId: string): Promise<{ + result: BrowserImportResult + handoff?: { tabId: string; url: string } + current?: boolean +}> { + const slot = slotsByScope.get(scopeId) + const handoff = slot?.handoff + if (!slot || !handoff) return { result: cookieFailure('unknown') } + const result = await runCookieImport( + handoff.profile.id, + { ...deps(), listProfiles: async () => [handoff.profile] }, + handoff.scope + ) + const current = slotsByScope.get(scopeId) === slot && slot.handoff === handoff + if (current && result.cookiesImported > 0) slotsByScope.delete(scopeId) + return { result, handoff, current } +} diff --git a/apps/desktop/src/main/browser-import/site-scope.test.ts b/apps/desktop/src/main/browser-import/site-scope.test.ts new file mode 100644 index 00000000000..5475fcb3824 --- /dev/null +++ b/apps/desktop/src/main/browser-import/site-scope.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { isCookieHostInScope, siteScopeFor } from '@/main/browser-import/site-scope' + +/** + * A per-site import must bring back the sign-in it was asked for and nothing + * else. Each case is a way the scope could leak another site's cookies into + * the built-in browser, or drop the ones the user just signed in for. + */ +describe('siteScopeFor', () => { + it.each([ + [ + 'a sign-in host widens to its registrable domain', + 'signin.aws.amazon.com', + 'amazon.com', + false, + ], + ['a private suffix tenant stays itself', 'alice.github.io', 'alice.github.io', false], + [ + 'an AWS access portal stays itself', + 'd-1234567890.awsapps.com', + 'd-1234567890.awsapps.com', + false, + ], + ['case and a root dot normalize', 'WWW.Example.COM.', 'example.com', false], + ['an IPv4 host is exact', '127.0.0.1', '127.0.0.1', true], + ['an IPv6 host is exact', '[::1]', '[::1]', true], + ['a single-label host is exact', 'localhost', 'localhost', true], + ])('%s', (_label, host, domain, exact) => { + expect(siteScopeFor(host)).toEqual({ domain, exact }) + }) + + it.each(['', '.'])('refuses the empty host %j', (host) => { + expect(siteScopeFor(host)).toBeNull() + }) +}) + +describe('isCookieHostInScope', () => { + function inScope(pageHost: string, hostKey: string): boolean { + const scope = siteScopeFor(pageHost) + if (!scope) throw new Error(`No scope for ${pageHost}`) + return isCookieHostInScope(hostKey, scope) + } + + it.each([ + ['www.example.com', 'example.com'], + ['www.example.com', 'sub.example.com'], + ['www.example.com', '.example.com'], + ['www.example.com', '.deep.sub.example.com'], + ['example.com', 'Sub.EXAMPLE.com'], + ['signin.aws.amazon.com', 'console.aws.amazon.com'], + ['alice.github.io', '.alice.github.io'], + ['127.0.0.1', '127.0.0.1'], + ['localhost', 'localhost'], + ])('a page on %s brings back %s', (pageHost, hostKey) => { + expect(inScope(pageHost, hostKey)).toBe(true) + }) + + it.each([ + ['example.com', 'example.com.evil.com'], + ['example.com', '.example.com.evil.com'], + ['example.com', 'notexample.com'], + ['example.com', '.notexample.com'], + ['example.com', 'evil.com'], + ['example.com', '.com'], + ['example.com', ''], + ['example.com', '.'], + ['signin.aws.amazon.com', 'amazon.co.uk'], + ['alice.github.io', 'bob.github.io'], + ['alice.github.io', '.github.io'], + ['127.0.0.1', '127.0.0.10'], + ['127.0.0.1', '10.127.0.0.1'], + ['localhost', 'evil.localhost'], + ])('a page on %s never brings back %s', (pageHost, hostKey) => { + expect(inScope(pageHost, hostKey)).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/browser-import/site-scope.ts b/apps/desktop/src/main/browser-import/site-scope.ts new file mode 100644 index 00000000000..d51bbddd8ef --- /dev/null +++ b/apps/desktop/src/main/browser-import/site-scope.ts @@ -0,0 +1,45 @@ +import { parse } from 'tldts' + +/** + * The cookies a per-site import may bring back from another browser. + * + * A sign-in rarely stays on the host the user started from — AWS signs in on + * `signin.aws.amazon.com` and lands on `console.aws.amazon.com` — so the scope + * is the page's registrable domain (eTLD+1), which covers both. Private + * registry suffixes count as public here (`github.io`, `awsapps.com`), so one + * tenant of a shared host never brings back its neighbours' cookies. + */ +export interface SiteScope { + /** The registrable domain, or the exact host when it has none. */ + domain: string + /** True when only cookies for `domain` itself qualify, never its subdomains. */ + exact: boolean +} + +/** + * Whether `host` is `domain` or one of its subdomains, matched on label + * boundaries so `notexample.com` and `example.com.evil.com` never qualify. + */ +export function isSameOrSubdomain(host: string, domain: string): boolean { + return host === domain || host.endsWith(`.${domain}`) +} + +/** The scope for a page on `hostname`, or null when there is no host to scope to. */ +export function siteScopeFor(hostname: string): SiteScope | null { + const host = hostname.trim().toLowerCase().replace(/\.$/, '') + if (host.length === 0) return null + const parsed = parse(host, { allowPrivateDomains: true }) + if (parsed.domain) return { domain: parsed.domain, exact: false } + // An IP, a single-label host, or a bare public suffix has no registrable + // parent to widen to, and its subdomains are other sites. + return { domain: host, exact: true } +} + +/** + * Whether a cookie stored under Chromium's `host_key` belongs to `scope`. A + * domain cookie on the suffix itself (`.github.io`) is no one tenant's sign-in. + */ +export function isCookieHostInScope(hostKey: string, scope: SiteScope): boolean { + const host = hostKey.toLowerCase().replace(/^\./, '') + return scope.exact ? host === scope.domain : isSameOrSubdomain(host, scope.domain) +} diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts index 8dcbb70033c..2cbb2eb6689 100644 --- a/apps/desktop/src/main/browser-import/sqlite-source.ts +++ b/apps/desktop/src/main/browser-import/sqlite-source.ts @@ -1,6 +1,7 @@ import { copyFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { SQLInputValue } from 'node:sqlite' import { ImportFailure } from '@/main/browser-import/types' /** @@ -17,7 +18,11 @@ import { ImportFailure } from '@/main/browser-import/types' /** SQLite keeps recent writes beside the main file; sidecars are needed for a faithful copy. */ const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal'] -async function queryCopy(databasePath: string, query: string): Promise[]> { +async function queryCopy( + databasePath: string, + query: string, + params: readonly SQLInputValue[] +): Promise[]> { // Imported lazily: `node:sqlite` is only needed on the import path, and this // keeps module load working on runtimes that lack it. const { DatabaseSync } = await import('node:sqlite') @@ -40,7 +45,7 @@ async function queryCopy(databasePath: string, query: string): Promise[] + return statement.all(...params) as Record[] } catch { throw new ImportFailure('unsupported-schema', 'The Chrome table is not in a recognised shape.') } finally { @@ -50,12 +55,14 @@ async function queryCopy(databasePath: string, query: string): Promise[]> { const staging = await mkdtemp(join(tmpdir(), 'sim-chrome-import-')) try { @@ -69,7 +76,7 @@ export async function queryBrowserDatabase( // Absent sidecars are normal: they only exist while a transaction is live. await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {}) } - return await queryCopy(workingCopy, query) + return await queryCopy(workingCopy, query, params) } finally { await rm(staging, { recursive: true, force: true }).catch(() => {}) } diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 3148c992577..28ca953f16f 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -92,6 +92,8 @@ export interface DesktopSettings { windowBounds?: WindowBounds zoomLevel?: number lastRoute?: string + /** Routes of the other app windows open when an update relaunch was committed. */ + updateRelaunchRoutes?: string[] themeBackground?: 'dark' | 'light' blockThirdPartyAnalytics?: boolean trayEnabled?: boolean diff --git a/apps/desktop/src/main/context-menu.ts b/apps/desktop/src/main/context-menu.ts index 689da904371..d0c88501507 100644 --- a/apps/desktop/src/main/context-menu.ts +++ b/apps/desktop/src/main/context-menu.ts @@ -9,14 +9,37 @@ export interface ContextMenuDeps { allowHttpLocalhost: boolean } -interface TemplateHandlers { +interface SpellingHandlers { replaceMisspelling(word: string): void addToDictionary(word: string): void +} + +interface TemplateHandlers extends SpellingHandlers { openLink(url: string): void copyLink(url: string): void inspect(x: number, y: number): void } +/** Corrections for the misspelled word under the cursor, then Add to Dictionary; empty when none. */ +export function spellingItems( + params: Pick, + handlers: SpellingHandlers +): MenuItemConstructorOptions[] { + if (!params.misspelledWord) return [] + const suggestions = params.dictionarySuggestions.slice(0, MAX_SUGGESTIONS) + return [ + ...(suggestions.length > 0 + ? suggestions.map( + (suggestion): MenuItemConstructorOptions => ({ + label: suggestion, + click: () => handlers.replaceMisspelling(suggestion), + }) + ) + : [{ label: 'No Guesses Found', enabled: false }]), + { label: 'Add to Dictionary', click: () => handlers.addToDictionary(params.misspelledWord) }, + ] +} + /** * Builds the native right-click menu for a given context. Returns an empty * template when there is nothing editable, selected, or linked — which leaves @@ -38,18 +61,8 @@ export function buildContextMenuTemplate( ): MenuItemConstructorOptions[] { const template: MenuItemConstructorOptions[] = [] - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, MAX_SUGGESTIONS)) { - template.push({ label: suggestion, click: () => handlers.replaceMisspelling(suggestion) }) - } - if (params.dictionarySuggestions.length === 0) { - template.push({ label: 'No Guesses Found', enabled: false }) - } - template.push( - { label: 'Add to Dictionary', click: () => handlers.addToDictionary(params.misspelledWord) }, - { type: 'separator' } - ) - } + const spelling = spellingItems(params, handlers) + if (spelling.length > 0) template.push(...spelling, { type: 'separator' }) if (params.isEditable) { template.push( diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index 951bc151f6c..22302421def 100644 --- a/apps/desktop/src/main/downloads.test.ts +++ b/apps/desktop/src/main/downloads.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { mkdtempSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -5,7 +6,35 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { sanitizeFilename, uniqueDownloadPath } from '@/main/downloads' +import type { DownloadItem, Session } from 'electron' +import { + attachDownloadHandling, + countProgressingAppDownloads, + sanitizeFilename, + uniqueDownloadPath, +} from '@/main/downloads' +import type { EventRecorder } from '@/main/observability' + +type DownloadState = ReturnType + +function startAppDownload(session: EventEmitter) { + const item = new EventEmitter() + let state: DownloadState = 'progressing' + Object.assign(item, { + getFilename: () => 'export.csv', + getMimeType: () => 'text/csv', + getSavePath: () => '/Downloads/export.csv', + getState: () => state, + setSaveDialogOptions: vi.fn(), + }) + session.emit('will-download', {}, item, undefined) + return { + finish(finalState: DownloadState) { + state = finalState + item.emit('done', {}, finalState) + }, + } +} describe('sanitizeFilename', () => { it('neutralizes path separators and leading dots', () => { @@ -49,3 +78,21 @@ describe('uniqueDownloadPath', () => { ) }) }) + +describe('countProgressingAppDownloads', () => { + it('counts an app-window download until it completes or is cancelled', () => { + const session = new EventEmitter() + const events = { record: vi.fn() } as unknown as EventRecorder + attachDownloadHandling(session as unknown as Session, events) + const before = countProgressingAppDownloads() + + const completed = startAppDownload(session) + const cancelled = startAppDownload(session) + expect(countProgressingAppDownloads()).toBe(before + 2) + + completed.finish('completed') + expect(countProgressingAppDownloads()).toBe(before + 1) + cancelled.finish('cancelled') + expect(countProgressingAppDownloads()).toBe(before) + }) +}) diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts index a26582f379b..718b3f7429c 100644 --- a/apps/desktop/src/main/downloads.ts +++ b/apps/desktop/src/main/downloads.ts @@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises' import { basename, extname, join } from 'node:path' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import type { Session } from 'electron' +import type { DownloadItem, Session } from 'electron' import { app } from 'electron' import { isAgentWebContents } from '@/main/browser-agent/registry' import type { EventRecorder } from '@/main/observability' @@ -131,6 +131,17 @@ export async function uniqueDownloadPath( return null } +const activeAppDownloads = new Set() + +/** App-window downloads (Files exports and saves) still receiving bytes, which a restart would cut off. */ +export function countProgressingAppDownloads(): number { + let count = 0 + for (const item of activeAppDownloads) { + if (item.getState() === 'progressing') count += 1 + } + return count +} + /** * Wires will-download so exports, blob URLs, and presigned-URL downloads all * get a native save dialog with a sensible default name, and completed @@ -139,11 +150,13 @@ export async function uniqueDownloadPath( export function attachDownloadHandling(session: Session, events: EventRecorder): void { session.on('will-download', (_event, item, contents) => { if (contents && isAgentWebContents(contents)) return + activeAppDownloads.add(item) const filename = suggestedFilename(item.getFilename(), item.getMimeType()) item.setSaveDialogOptions({ defaultPath: join(app.getPath('downloads'), filename), }) item.once('done', (_doneEvent, state) => { + activeAppDownloads.delete(item) if (state === 'completed') { logger.info('Download completed', { filename }) if (process.platform === 'darwin') { diff --git a/apps/desktop/src/main/in-flight-work.test.ts b/apps/desktop/src/main/in-flight-work.test.ts new file mode 100644 index 00000000000..4a218ab7268 --- /dev/null +++ b/apps/desktop/src/main/in-flight-work.test.ts @@ -0,0 +1,73 @@ +import type { WebContents } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import { + countWindowsWithUnsavedChanges, + describeInFlightWork, + type InFlightWork, +} from '@/main/in-flight-work' + +vi.mock('electron', () => import('@/test/electron-mock')) + +function windowAnswering(answer: () => Promise, state = { destroyed: false }) { + return { + isDestroyed: () => state.destroyed, + isCrashed: () => false, + executeJavaScript: vi.fn(answer), + } as unknown as WebContents +} + +describe('countWindowsWithUnsavedChanges', () => { + it('never reports a window it could not ask as clean', async () => { + const counts = await countWindowsWithUnsavedChanges([ + windowAnswering(() => Promise.resolve(true)), + windowAnswering(() => Promise.resolve(false)), + windowAnswering(() => Promise.reject(new Error('Script failed to execute'))), + windowAnswering(() => Promise.resolve(true), { destroyed: true }), + ]) + + expect(counts).toEqual({ unsavedWindowCount: 1, unresponsiveWindowCount: 1 }) + }) +}) + +describe('describeInFlightWork', () => { + const IDLE: InFlightWork = { + terminalCommands: [], + uncheckedTerminalCount: 0, + computerUseApp: null, + browserToolRunning: false, + agentWorkRunning: false, + downloadCount: 0, + unsavedWindowCount: 0, + unresponsiveWindowCount: 0, + } + + it('warns about a terminal it could not check rather than restarting silently', () => { + expect(describeInFlightWork({ ...IDLE, uncheckedTerminalCount: 2 })).toEqual([ + "2 terminals couldn't be checked and may still be running commands.", + ]) + expect(describeInFlightWork({ ...IDLE, uncheckedTerminalCount: 1 })).toEqual([ + "A terminal couldn't be checked and may still be running a command.", + ]) + }) + + it('starts the sentence for an unannounced command with a capital', () => { + expect(describeInFlightWork({ ...IDLE, terminalCommands: [''] })).toEqual([ + 'A command is still running in a terminal.', + ]) + expect(describeInFlightWork({ ...IDLE, terminalCommands: ['npm run dev'] })).toEqual([ + 'npm is still running in a terminal.', + ]) + }) + + it('counts unannounced commands rather than naming "a command" twice', () => { + expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', ''] })).toEqual([ + '2 terminal commands are still running.', + ]) + expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev'] })).toEqual([ + 'npm and a command are still running in terminals.', + ]) + expect( + describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev', 'claude'] }) + ).toEqual(['3 terminal commands are still running.']) + }) +}) diff --git a/apps/desktop/src/main/in-flight-work.ts b/apps/desktop/src/main/in-flight-work.ts new file mode 100644 index 00000000000..3203b675a85 --- /dev/null +++ b/apps/desktop/src/main/in-flight-work.ts @@ -0,0 +1,148 @@ +import { createLogger } from '@sim/logger' +import { describeRunningCommand } from '@sim/terminal-protocol' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import type { WebContents } from 'electron' + +const logger = createLogger('DesktopInFlightWork') + +/** A renderer that cannot answer this quickly is treated as hung, holding edits it cannot report. */ +const UNSAVED_CHANGES_PROBE_TIMEOUT_MS = 1_000 +const MAX_NAMED_TERMINAL_COMMANDS = 2 + +/** + * Asks the page's own `beforeunload` guards whether they would hold it open, without unloading it, + * so every editor's live dirty state counts and no renderer needs to know about the shell. + */ +const UNSAVED_CHANGES_PROBE = `(() => { + let event + try { + event = document.createEvent('BeforeUnloadEvent') + event.initEvent('beforeunload', false, true) + } catch { + event = new Event('beforeunload', { cancelable: true }) + } + window.dispatchEvent(event) + return event.defaultPrevented || (typeof event.returnValue === 'string' && event.returnValue !== '') +})()` + +type UnloadAnswer = 'clean' | 'unsaved' | 'unresponsive' + +async function askAboutUnsavedChanges(contents: WebContents): Promise { + if (contents.isDestroyed() || contents.isCrashed()) return 'clean' + try { + const answer = await Promise.race([ + contents.executeJavaScript(UNSAVED_CHANGES_PROBE), + sleep(UNSAVED_CHANGES_PROBE_TIMEOUT_MS).then(() => 'unresponsive' as const), + ]) + if (answer === 'unresponsive') return answer + return answer === true ? 'unsaved' : 'clean' + } catch (error) { + logger.warn('Could not ask a window about unsaved changes', { + message: getErrorMessage(error, 'unknown'), + }) + return 'unresponsive' + } +} + +/** + * Counts the windows whose pages would ask before leaving, and those too hung + * to answer, which may be holding edits just the same. + */ +export async function countWindowsWithUnsavedChanges( + windows: readonly WebContents[] +): Promise> { + const answers = await Promise.all(windows.map(askAboutUnsavedChanges)) + return { + unsavedWindowCount: answers.filter((answer) => answer === 'unsaved').length, + unresponsiveWindowCount: answers.filter((answer) => answer === 'unresponsive').length, + } +} + +export interface InFlightWork { + /** The command line holding each busy terminal's foreground (`''` when unannounced). */ + terminalCommands: readonly string[] + /** Terminals whose foreground could not be read, which may be running a command. */ + uncheckedTerminalCount: number + /** The app a computer-use action is driving, `''` when it has no name, or null when idle. */ + computerUseApp: string | null + /** Whether an agent has a browser action queued or running. */ + browserToolRunning: boolean + /** Whether a chat has desktop work claimed on this machine. */ + agentWorkRunning: boolean + /** Agent-browser and app-window downloads still in progress. */ + downloadCount: number + unsavedWindowCount: number + unresponsiveWindowCount: number +} + +/** + * One sentence for the busy terminals. Commands are named while at most two are running and + * at most one is unannounced; past that, two "a command"s would say nothing, so they are counted. + */ +function describeTerminalCommands(commands: readonly string[]): string | null { + if (commands.length === 0) return null + const named = commands.filter((command) => command.trim()).map(describeRunningCommand) + const unnamed = commands.length - named.length + if (unnamed > 1 || commands.length > MAX_NAMED_TERMINAL_COMMANDS) { + return `${commands.length} terminal commands are still running.` + } + const labels = unnamed === 1 ? [...named, describeRunningCommand('')] : named + const sentence = + labels.length > 1 + ? `${labels.join(' and ')} are still running in terminals.` + : `${labels[0]} is still running in a terminal.` + // Program names keep their own case; only the generic "a command" label is capitalized. + return named.length === 0 ? sentence.charAt(0).toUpperCase() + sentence.slice(1) : sentence +} + +/** + * Names the work a restart would interrupt, one sentence per kind, so a + * restart prompt says exactly what is at stake. Empty when nothing is. + */ +export function describeInFlightWork(work: InFlightWork): string[] { + const sentences: string[] = [] + const terminalSentence = describeTerminalCommands(work.terminalCommands) + if (terminalSentence) sentences.push(terminalSentence) + if (work.uncheckedTerminalCount === 1) { + sentences.push("A terminal couldn't be checked and may still be running a command.") + } else if (work.uncheckedTerminalCount > 1) { + sentences.push( + `${work.uncheckedTerminalCount} terminals couldn't be checked and may still be running commands.` + ) + } + + if (work.computerUseApp !== null) { + sentences.push( + work.computerUseApp + ? `An agent is using ${work.computerUseApp}.` + : 'An agent is using an app on this computer.' + ) + } + if (work.browserToolRunning) { + sentences.push('An agent is using the browser.') + } + if (work.agentWorkRunning && work.computerUseApp === null && !work.browserToolRunning) { + sentences.push('An agent is still working on this computer.') + } + + if (work.downloadCount === 1) { + sentences.push('A download is still in progress.') + } else if (work.downloadCount > 1) { + sentences.push(`${work.downloadCount} downloads are still in progress.`) + } + + if (work.unsavedWindowCount === 1) { + sentences.push('A window has unsaved changes.') + } else if (work.unsavedWindowCount > 1) { + sentences.push(`${work.unsavedWindowCount} windows have unsaved changes.`) + } + if (work.unresponsiveWindowCount === 1) { + sentences.push("A window isn't responding and may have unsaved changes.") + } else if (work.unresponsiveWindowCount > 1) { + sentences.push( + `${work.unresponsiveWindowCount} windows aren't responding and may have unsaved changes.` + ) + } + return sentences +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 56c4261fe10..a0efc04faf5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -37,6 +37,7 @@ import { executeTool as executeAgentBrowserTool, hasBrowserScopeSession, initDriver as initBrowserAgentDriver, + isBrowserToolRunning, restoreBrowserScope as restoreAgentBrowserScope, } from '@/main/browser-agent/driver' import { @@ -46,6 +47,7 @@ import { setPanelOccluded as setBrowserAgentPanelOccluded, } from '@/main/browser-agent/panel' import { + countProgressingBrowserDownloads, handleFocusedShortcut as handleFocusedBrowserShortcut, isBrowserScopeSuspended, quiesceBrowserSessions, @@ -53,6 +55,7 @@ import { setBrowserAppearanceTheme as setAgentBrowserTheme, setPanelFocused as setBrowserAgentPanelFocused, } from '@/main/browser-agent/session' +import { clearSignIns } from '@/main/browser-import' import { attachClientInfo } from '@/main/client-info' import { NativeComputerUseClient } from '@/main/computer-use/native-client' import { ComputerUseService } from '@/main/computer-use/service' @@ -72,12 +75,13 @@ import { createDesktopToolRunner } from '@/main/desktop-executor/runner' import { createDesktopExecutorService } from '@/main/desktop-executor/service' import { createSleepBlocker } from '@/main/desktop-executor/sleep-blocker' import { createDesktopSettingsService } from '@/main/desktop-settings' -import { attachDownloadHandling } from '@/main/downloads' +import { attachDownloadHandling, countProgressingAppDownloads } from '@/main/downloads' import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff' import { installDocumentationHelpSearch, uninstallDocumentationHelpSearch, } from '@/main/help-search' +import { countWindowsWithUnsavedChanges, describeInFlightWork } from '@/main/in-flight-work' import { registerIpcHandlers } from '@/main/ipc' import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' import { LocalFilePermissions } from '@/main/local-file-permissions' @@ -128,6 +132,9 @@ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } +/** Bounds what a hand-edited settings file can make an update relaunch open. */ +const MAX_RELAUNCH_WINDOWS = 16 + const DOCK_ICON_FOR_CHANNEL = { prod: 'dock-icon.png', staging: 'dock-icon-staging.png', @@ -390,6 +397,7 @@ function main(): void { clearHandoffState: async () => { const stores = [ { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'browser sign-in handoffs', clear: clearSignIns }, { label: 'recent tasks', clear: () => tray?.clearRecentChats() }, { label: 'renderer session state', @@ -531,6 +539,12 @@ function main(): void { win.on('focus', () => { lastActiveWindow = win }) + // Windows and Linux report a mouse's back and forward buttons as app + // commands. They move the Browser tab the user is in, never the Sim shell. + win.on('app-command', (_event, command) => { + if (command === 'browser-backward') handleFocusedBrowserShortcut('back', win) + else if (command === 'browser-forward') handleFocusedBrowserShortcut('forward', win) + }) // A fresh document (reload, origin change, crash recovery) has no browser // panel mounted yet — hide the embedded agent-browser view immediately // rather than letting it linger over the loading page. @@ -569,6 +583,42 @@ function main(): void { return win } + /** + * An update relaunch reopens every app window where it was: the active one + * through `lastRoute` like any launch, the rest from this list. Written only + * once the relaunch is committed, and withdrawn if it fails. + */ + function rememberWindowsForRelaunch(committed: boolean): void { + if (!committed) { + if (config.get('updateRelaunchRoutes') === undefined) return + config.set('updateRelaunchRoutes', undefined) + config.flush() + return + } + const active = getMainWindow() + const activeRoute = active ? routeFromAppUrl(active.webContents.getURL()) : null + if (activeRoute) config.set('lastRoute', activeRoute) + const otherRoutes = getWindows() + .filter((win) => win !== active) + .flatMap((win) => routeFromAppUrl(win.webContents.getURL()) ?? []) + config.set('updateRelaunchRoutes', otherRoutes.length > 0 ? otherRoutes : undefined) + config.flush() + } + + /** Reopens the windows an update relaunch remembered, once the active window is up. */ + function restoreWindowsAfterRelaunch(): void { + const routes: unknown = config.get('updateRelaunchRoutes') + if (routes === undefined) return + config.set('updateRelaunchRoutes', undefined) + config.flush() + if (!Array.isArray(routes)) return + for (const route of routes.slice(0, MAX_RELAUNCH_WINDOWS)) { + if (typeof route === 'string' && decideStartRoute(route) === route) { + void createAndLoadAppWindow({ route }) + } + } + } + /** Opens the Sim app's settings page in the active window. */ function openSettings(): void { void openMainWindowAt(settingsRoute(config.get('lastRoute'))) @@ -679,6 +729,7 @@ function main(): void { Notification.isSupported() ? new Notification(options) : null, }) + let agentWorkRunning = false const desktopExecutor = createDesktopExecutorService({ userDataPath, origin: appOrigin, @@ -686,7 +737,10 @@ function main(): void { preferences: () => desktopSettings.getPreferences(), accountDataAvailable, onApprovals: (items) => approvalNotifier.update(items), - onBusyChange: (busy) => sleepBlocker.setBusy(busy), + onBusyChange: (busy) => { + agentWorkRunning = busy + sleepBlocker.setBusy(busy) + }, // A result the model has makes a tmux run it handed back as still going collectable across a // restart. onResultDelivered: (toolCallId) => terminal.markRunDelivered(toolCallId), @@ -889,6 +943,7 @@ function main(): void { ...(kind === 'account' && origin ? [ { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'browser sign-in handoffs', clear: clearSignIns }, { label: 'background executor', clear: () => desktopExecutor.signOut() }, { label: 'terminal sessions', @@ -1064,7 +1119,7 @@ function main(): void { updates: { getState: () => updater?.getState() ?? { status: 'idle' }, check: () => updater?.check(), - install: () => updater?.install(), + install: (options) => updater?.install(options), }, server: { open: () => serverWindow.open(), @@ -1113,6 +1168,24 @@ function main(): void { autoDownload: () => config.get('autoDownloadUpdates') ?? true, setRelaunchPending: (pending) => { committedRelaunchPending = pending + rememberWindowsForRelaunch(pending) + }, + describeInFlightWork: async () => { + // Native activity is read after the window probes so work started meanwhile still counts. + const windows = await countWindowsWithUnsavedChanges( + getWindows().map((win) => win.webContents) + ) + const terminalWork = await terminal.runningWork() + const computerUseActivity = computerUse.getActivity() + return describeInFlightWork({ + terminalCommands: terminalWork.commands, + uncheckedTerminalCount: terminalWork.uncheckedCount, + computerUseApp: computerUseActivity ? (computerUseActivity.appName ?? '') : null, + browserToolRunning: isBrowserToolRunning(), + agentWorkRunning, + downloadCount: countProgressingBrowserDownloads() + countProgressingAppDownloads(), + ...windows, + }) }, beforeInstall: async () => { if (!prepareAccountDataTeardownForQuit()) { @@ -1129,6 +1202,7 @@ function main(): void { }, }) desktopSettings.applySystemPreferences() + restoreWindowsAfterRelaunch() }) } diff --git a/apps/desktop/src/main/input-activity.ts b/apps/desktop/src/main/input-activity.ts index 1e1579edcfa..e67bbeebc88 100644 --- a/apps/desktop/src/main/input-activity.ts +++ b/apps/desktop/src/main/input-activity.ts @@ -132,6 +132,11 @@ function isWithin( if (contents.isDestroyed()) return false const activity = activityByContents.get(contents) if (!activity) return false - const elapsed = Date.now() - stamp(activity) + return withinWindow(stamp(activity), windowMs) +} + +/** Whether `at` lies in the last `windowMs`; a clock stepping backwards never passes. */ +export function withinWindow(at: number, windowMs: number): boolean { + const elapsed = Date.now() - at return elapsed >= 0 && elapsed < windowMs } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index aeece41f983..b5580f76393 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -20,6 +20,17 @@ vi.mock('@/main/browser-import', () => ({ passwordsUpdated: 0, passwordsSkipped: 1, })), + continueSignIn: vi.fn(async () => ({ + pending: { site: 'example.com', browserLabel: 'Chrome' }, + })), + bringSignInBack: vi.fn(async () => ({ + result: { cookiesImported: 2, cookiesSkipped: 0 }, + handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' }, + current: true, + })), + cancelSignIn: vi.fn(), + getPendingSignIn: vi.fn(() => null), + migrateSignIn: vi.fn(), })) vi.mock('@/main/browser-search/suggestions', () => ({ @@ -135,9 +146,12 @@ import { revealCredential, } from '@/main/browser-credentials' import { + bringSignInBack, + continueSignIn, importChromeCookies, importChromePasswords, listChromeImportProfiles, + migrateSignIn, } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { ComputerUseService } from '@/main/computer-use/service' @@ -1138,11 +1152,23 @@ describe('registerIpcHandlers', () => { }) expect(migrate).not.toHaveBeenCalled() + expect(migrateSignIn).not.toHaveBeenCalled() expect(deps.scopeEvents.activateBrowser).toHaveBeenCalledTimes(1) migrate.mockRestore() }) + it('moves a pending sign-in with the browser scope when a new chat gets its id', async () => { + const migrate = vi.spyOn(browserDriver, 'migrateBrowserScope').mockReturnValue(true) + const { invoke } = collectHandlers() + + await invoke.get('browser-agent:activate-scope')?.(appEvent, 'pending:signin') + await invoke.get('browser-agent:migrate-scope')?.(appEvent, 'pending:signin', 'chat-saved') + + expect(migrateSignIn).toHaveBeenCalledWith('pending:signin', 'chat-saved') + migrate.mockRestore() + }) + it('requires a live user gesture before importing Chrome cookies', async () => { const { invoke } = collectHandlers() const handler = invoke.get('browser-import:cookies') @@ -1164,6 +1190,161 @@ describe('registerIpcHandlers', () => { expect(importChromeCookies).toHaveBeenCalledWith('Default') }) + describe('continuing a sign-in in another browser', () => { + const SIGN_IN_URL = 'https://signin.example.com/login' + let tabUrl = SIGN_IN_URL + let active: ReturnType + + beforeEach(async () => { + tabUrl = SIGN_IN_URL + active = vi.spyOn(browserSession, 'activeTab').mockReturnValue({ + id: 'tab-1', + view: { webContents: { getURL: () => tabUrl } }, + } as never) + await collectHandlers().invoke.get('browser-agent:activate-scope')?.( + activeAppEvent, + 'chat-signin' + ) + }) + + afterEach(() => active.mockRestore()) + + const continueIn = (event: typeof activeAppEvent, ...args: unknown[]) => + collectHandlers().invoke.get('browser-import:continue-sign-in')?.(event, ...args) + + it('hands over the page the tab shows, ignoring any address the renderer names', async () => { + await expect( + continueIn(activeAppEvent, 'chrome:Default', 'chat-signin', 'https://evil.example/') + ).resolves.toEqual({ pending: { site: 'example.com', browserLabel: 'Chrome' } }) + expect(continueSignIn).toHaveBeenCalledWith('chat-signin', { + profileId: 'chrome:Default', + tabId: 'tab-1', + url: SIGN_IN_URL, + }) + }) + + it('refuses a renderer acting without the user', async () => { + await collectHandlers().invoke.get('browser-agent:activate-scope')?.( + inactiveAppEvent, + 'chat-scripted' + ) + expect(await continueIn(inactiveAppEvent, 'chrome:Default', 'chat-scripted')).toEqual({ + error: 'unknown', + }) + expect(continueSignIn).not.toHaveBeenCalled() + }) + + it('refuses a foreign origin and a malformed profile', async () => { + expect(await continueIn(evilEvent, 'chrome:Default', 'chat-signin')).toEqual({ + error: 'unknown', + }) + expect(await continueIn(activeAppEvent, 42, 'chat-signin')).toEqual({ error: 'unknown' }) + expect(continueSignIn).not.toHaveBeenCalled() + }) + + it("refuses Sim's own pages, which ride the app session", async () => { + tabUrl = `${APP}/workspace/ws1/files` + expect(await continueIn(activeAppEvent, 'chrome:Default', 'chat-signin')).toEqual({ + error: 'unsupported-page', + }) + expect(continueSignIn).not.toHaveBeenCalled() + }) + }) + + describe('bringing a sign-in back', () => { + let panelAction: ReturnType + let peek: ReturnType + const tabsWith = (...tabIds: string[]) => ({ + scopeId: 'chat-signin', + tabs: tabIds.map((tabId) => ({ tabId, url: '', title: '', loading: false, active: false })), + activeTabId: 'tab-2', + }) + + beforeEach(async () => { + panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue() + peek = vi.spyOn(browserSession, 'peekTabsState').mockReturnValue(tabsWith('tab-1', 'tab-2')) + await collectHandlers().invoke.get('browser-agent:activate-scope')?.( + activeAppEvent, + 'chat-signin' + ) + }) + + afterEach(() => { + panelAction.mockRestore() + peek.mockRestore() + }) + + const bringBack = (event: typeof activeAppEvent, scope: string) => + collectHandlers().invoke.get('browser-import:bring-sign-in-back')?.(event, scope) + + it('refuses a renderer acting without the user, and a foreign origin', async () => { + await collectHandlers().invoke.get('browser-agent:activate-scope')?.( + inactiveAppEvent, + 'chat-scripted' + ) + expect(await bringBack(inactiveAppEvent, 'chat-scripted')).toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + expect(await bringBack(evilEvent, 'chat-signin')).toMatchObject({ error: 'unknown' }) + expect(bringSignInBack).not.toHaveBeenCalled() + }) + + it('reopens the handed-off page in its own tab', async () => { + await expect(bringBack(activeAppEvent, 'chat-signin')).resolves.toEqual({ + cookiesImported: 2, + cookiesSkipped: 0, + }) + expect(bringSignInBack).toHaveBeenCalledWith('chat-signin') + expect(panelAction.mock.calls).toEqual([ + ['chat-signin', { action: 'switch-tab', tabId: 'tab-1' }], + ['chat-signin', { action: 'navigate', url: 'https://signin.example.com/login' }], + ]) + }) + + it('reopens the page in a new tab when its tab was closed', async () => { + peek.mockReturnValue(tabsWith('tab-2')) + const tabContents = { loadURL: vi.fn(async () => {}), isDestroyed: () => false } + const add = vi + .spyOn(browserSession, 'addTab') + .mockReturnValue({ view: { webContents: tabContents } } as never) + const focus = vi.spyOn(browserSession, 'focusPageForUser').mockImplementation(() => {}) + + await bringBack(activeAppEvent, 'chat-signin') + + expect(add).toHaveBeenCalledOnce() + expect(tabContents.loadURL).toHaveBeenCalledWith('https://signin.example.com/login') + expect(panelAction).not.toHaveBeenCalled() + add.mockRestore() + focus.mockRestore() + }) + + it('leaves the tabs alone when a newer continue retired the handoff mid-import', async () => { + vi.mocked(bringSignInBack).mockResolvedValueOnce({ + result: { cookiesImported: 2, cookiesSkipped: 0 }, + handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' }, + current: false, + }) + + await bringBack(activeAppEvent, 'chat-signin') + + expect(panelAction).not.toHaveBeenCalled() + }) + + it('leaves the tabs alone when nothing came back', async () => { + vi.mocked(bringSignInBack).mockResolvedValueOnce({ + result: { cookiesImported: 0, cookiesSkipped: 0 }, + handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' }, + current: true, + }) + + await bringBack(activeAppEvent, 'chat-signin') + + expect(panelAction).not.toHaveBeenCalled() + }) + }) + it('never imports Chrome cookies for a foreign origin', async () => { const { invoke } = collectHandlers() diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index ebd34d86e1f..b88167462ac 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -57,6 +57,7 @@ import { } from '@/main/browser-agent/driver' import { isAgentWebContents } from '@/main/browser-agent/registry' import { + activeTab, addTab, findInActiveTab, focusPageForUser, @@ -79,10 +80,15 @@ import { revealCredential, } from '@/main/browser-credentials' import { + bringSignInBack, + cancelSignIn, + continueSignIn, + getPendingSignIn, importChromeCookies, importChromeData, importChromePasswords, listChromeImportProfiles, + migrateSignIn, } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { listSites } from '@/main/browser-sites' @@ -385,7 +391,7 @@ export interface IpcDeps { updates: { getState: () => DesktopUpdateState check: () => void - install: () => void + install: (options: { mandatory: boolean }) => void } server: { open: () => void @@ -1016,7 +1022,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { kind: 'send', gate: 'app-origin', needsUserActivation: true, - handler: () => deps.updates.install(), + handler: (options) => + deps.updates.install({ mandatory: isRecordLike(options) && options.mandatory === true }), }, 'browser-agent:execute-tool': { kind: 'invoke', @@ -1176,6 +1183,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { return { tabs: [], activeTabId: null } } consumePendingScope(browserPendingScopesBySender, contents, from) + migrateSignIn(from, to) if (browserScopeBySender.get(contents) === from) { browserScopeBySender.set(contents, to) deps.scopeEvents.activateBrowser(contents, to) @@ -1312,6 +1320,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { needsUserActivation: ([action]) => { if (!isRecordLike(action)) return false if (action.action === 'navigate') return true + if (action.action === 'respond-prompt') { + return isRecordLike(action.response) && action.response.allowed === true + } return action.action === 'respond-media-permission' && action.allowed === true }, handler: (sender, action, rawScope) => { @@ -1512,6 +1523,78 @@ export function registerIpcHandlers(deps: IpcDeps): void { ) }, }, + // Continue a sign-in in the user's own browser (see the bridge's + // `continueSignIn`). The shell reads the address from the tab, never from + // the renderer; Sim's own pages ride the app session, so they are refused. + 'browser-import:continue-sign-in': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + passSender: true, + needsUserActivation: true, + denied: { error: 'unknown' }, + handler: (sender, profileId, rawScope) => { + const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) + if (!scopeId || typeof profileId !== 'string') return { error: 'unknown' } + const tab = withBrowserScope(scopeId, () => activeTab()) + const url = tab?.view.webContents.getURL() ?? '' + if (!tab || isAppOrigin(url, deps.appOrigin())) return { error: 'unsupported-page' } + return continueSignIn(scopeId, { profileId, tabId: tab.id, url }) + }, + }, + // Imports the chat's pending sign-in, then reopens the handed-off page + // through the omnibox's navigation path, or in a new tab if it was closed. + 'browser-import:bring-sign-in-back': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + passSender: true, + needsUserActivation: true, + denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + handler: async (sender, rawScope) => { + const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) + if (!scopeId) return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' } + const { result, handoff, current } = await bringSignInBack(scopeId) + if (!handoff || !current || result.cookiesImported === 0) return result + const tabOpen = withBrowserScope(scopeId, () => + peekTabsState().tabs.some(({ tabId }) => tabId === handoff.tabId) + ) + if (!tabOpen) { + withBrowserScope(scopeId, () => { + const tab = addTab() + void tab.view.webContents.loadURL(handoff.url).catch(() => {}) + focusPageForUser(tab.view.webContents) + }) + return result + } + await handlePanelAction(scopeId, { action: 'switch-tab', tabId: handoff.tabId }) + .then(() => handlePanelAction(scopeId, { action: 'navigate', url: handoff.url })) + .catch(() => {}) + return result + }, + }, + 'browser-import:cancel-sign-in': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + passSender: true, + denied: undefined, + handler: (sender, rawScope) => { + const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) + if (scopeId) cancelSignIn(scopeId) + }, + }, + 'browser-import:pending-sign-in': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + passSender: true, + denied: null, + handler: (sender, rawScope) => { + const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) + return scopeId ? getPendingSignIn(scopeId) : null + }, + }, // Reported by the browser preload for the page it is running in. The // origin it names is a claim: the fill coordinator re-checks it against // the live URL before any password is read. diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts index d902421c109..bb4c69a2d17 100644 --- a/apps/desktop/src/main/navigation.test.ts +++ b/apps/desktop/src/main/navigation.test.ts @@ -4,6 +4,7 @@ vi.mock('electron', () => import('@/test/electron-mock')) import { shell } from 'electron' import { + appLinkTarget, classifyNavigation, classifyWindowOpen, isSafeExternalUrl, @@ -61,6 +62,12 @@ describe('classifyNavigation', () => { ).toBe('external') }) + it('sends mailto links to the mail client', () => { + expect( + classifyNavigation('mailto:support@sim.ai', { appOrigin: APP, currentUrl: `${APP}/login` }) + ).toBe('external') + }) + it('denies non-web schemes everywhere', () => { for (const url of [ 'javascript:alert(1)', @@ -75,6 +82,10 @@ describe('classifyNavigation', () => { }) describe('classifyWindowOpen', () => { + it('sends mailto popups to the mail client instead of denying them', () => { + expect(classifyWindowOpen('mailto:support@sim.ai', '_blank', APP)).toBe('external') + }) + it('does not let a cross-origin http URL ride the mcp-oauth frame name in-app', () => { expect(classifyWindowOpen('http://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( 'external' @@ -106,4 +117,70 @@ describe('openExternalSafe', () => { await expect(openExternalSafe('file:///etc/passwd', true)).resolves.toBe(false) expect(shell.openExternal).not.toHaveBeenCalled() }) + + it.each([ + 'mailto:support@sim.ai', + 'MAILTO:support@sim.ai,help@sim.ai?subject=Desktop%20offline&body=Hi%0Athere', + 'mailto:?to=support@sim.ai&cc=a@b.example', + ])('hands the well-formed mailto %s to the system mail client', async (url) => { + await expect(openExternalSafe(url)).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenCalledWith(url) + }) + + it.each([ + ['an attachment field', 'mailto:a@b.example?attach=/etc/passwd'], + ['an unlisted header', 'mailto:a@b.example?X-Header=y'], + ['a non-address recipient field', 'mailto:a@b.example?cc=file:///etc/passwd'], + ['unencoded whitespace', 'mailto:a@b.example?subject=a b'], + ['an encoded header injection', 'mailto:a@b.example%0d%0aBcc:c@d.example'], + ['a fragment', 'mailto:a@b.example#frag'], + ])('refuses a mailto with %s', async (_rule, url) => { + await expect(openExternalSafe(url)).resolves.toBe(false) + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) + +describe('appLinkTarget', () => { + it.each([ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'ms-msdt:id', + 'search-ms:query=x', + 'ms-cxh-full://0', + 'local+app://x', + 'jnlp://evil.example/app.jnlp', + 'x-man-page://ls', + 'c:/Windows/System32/calc.exe', + 'https://example.com', + ])('refuses %s', (url) => { + expect(appLinkTarget(url)).toBeNull() + }) + + it('refuses credentials, control characters, oversize URLs, and unsafe mail links', () => { + expect(appLinkTarget('zoommtg://user:pass@zoom.us/join')).toBeNull() + expect(appLinkTarget('zoommtg://zoom.us/join?x=1\n')).toBeNull() + expect(appLinkTarget('zoommtg://zoom.us/join?x= 1')).toBeNull() + expect(appLinkTarget(`zoommtg://zoom.us/join?x=${'a'.repeat(2_048)}`)).toBeNull() + expect(appLinkTarget('mailto:a@example.com?attach=/etc/passwd')).toBeNull() + }) + + it('hands the operating system the parser-rebuilt URL of an app link', () => { + expect(appLinkTarget('ZoomMtg://zoom.us/join?confno=123&pwd=abc')).toEqual({ + url: 'zoommtg://zoom.us/join?confno=123&pwd=abc', + scheme: 'zoommtg', + }) + expect(appLinkTarget('mailto:a@example.com?subject=Hi')?.scheme).toBe('mailto') + }) + + it('opens an app link only on the app-link path, and only in its rebuilt form', async () => { + vi.mocked(shell.openExternal).mockClear() + await expect(openExternalSafe('zoommtg://zoom.us/join')).resolves.toBe(false) + await expect( + openExternalSafe('ZoomMtg://zoom.us/join', false, { appLink: true }) + ).resolves.toBe(false) + await expect( + openExternalSafe('zoommtg://zoom.us/join', false, { appLink: true }) + ).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join') + }) }) diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts index 3131a5c125e..5d69e18e4d6 100644 --- a/apps/desktop/src/main/navigation.ts +++ b/apps/desktop/src/main/navigation.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/ssrf' +import { isValidEmailSyntax } from '@sim/utils/string' import { shell } from 'electron' import { scrubUrl } from '@/main/observability' @@ -112,6 +113,9 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main if (rawUrl === 'about:blank') { return 'in-app' } + if (isMailtoUrl(rawUrl)) { + return 'external' + } const url = parseHttpUrl(rawUrl) if (!url) { return 'deny' @@ -146,9 +150,12 @@ export function classifyWindowOpen( frameName: string, appOrigin: string ): WindowOpenAction { - if (rawUrl === '' || rawUrl === 'about:blank') { + if (isBlankPopupUrl(rawUrl)) { return 'popup-blank' } + if (isMailtoUrl(rawUrl)) { + return 'external' + } const url = parseHttpUrl(rawUrl) if (!url) { return 'deny' @@ -174,7 +181,7 @@ export function classifyWindowOpen( * by the blank-then-assign pattern. */ export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string): BlankChildAction { - if (rawUrl === '' || rawUrl === 'about:blank') { + if (isBlankPopupUrl(rawUrl)) { return 'ignore' } const url = parseHttpUrl(rawUrl) @@ -187,6 +194,54 @@ export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string): return 'external' } +/** The URL `window.open('')` and `window.open('about:blank')` hand a popup handler. */ +export function isBlankPopupUrl(url: string): boolean { + return url === '' || url === 'about:blank' +} + +function isMailtoUrl(raw: string): boolean { + return /^mailto:/i.test(raw) +} + +/** RFC 6068 fields a mail client fills in; anything else (`attach=`, arbitrary headers) is refused. */ +const MAILTO_FIELDS = new Set(['to', 'cc', 'bcc', 'subject', 'body']) + +function isMailtoAddressList(encoded: string): boolean { + if (encoded === '') return true + try { + return decodeURIComponent(encoded) + .split(',') + .every((address) => isValidEmailSyntax(address.trim())) + } catch { + return false + } +} + +/** + * A `mailto:` of printable ASCII, plain addresses, and compose fields only: some mail clients + * honour `attach=` and other headers, which would let a page attach a local file. + */ +function isSafeMailtoUrl(raw: string): boolean { + if (!isMailtoUrl(raw) || raw.length > 4_096 || !/^[\x21-\x7e]+$/.test(raw)) { + return false + } + const [addresses = '', query, ...rest] = raw.slice(7).split('?') + if (rest.length > 0 || addresses.includes('#') || !isMailtoAddressList(addresses)) { + return false + } + if (query === undefined) return true + for (const field of query.split('&')) { + const separator = field.indexOf('=') + const name = (separator === -1 ? field : field.slice(0, separator)).toLowerCase() + if (!MAILTO_FIELDS.has(name)) return false + const value = separator === -1 ? '' : field.slice(separator + 1) + if ((name === 'to' || name === 'cc' || name === 'bcc') && !isMailtoAddressList(value)) { + return false + } + } + return true +} + /** * Validates a URL for handing to the system browser: https always, http only * for loopback hosts when explicitly allowed, never credentials in the URL, @@ -212,11 +267,111 @@ export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): bool } /** - * Opens a URL in the system browser after validation. Every openExternal in - * the app goes through here — menu items, IPC, and navigation policy. + * Schemes that reach local files, network shares, script, browser internals, + * or Windows/macOS handlers with a record of remote-code abuse. A browser page + * never opens them, even with a registered handler and the user's click. + */ +const DENIED_APP_LINK_SCHEMES: ReadonlySet = new Set([ + 'about', + 'afp', + 'applescript', + 'blob', + 'chrome', + 'chrome-extension', + 'chrome-untrusted', + 'data', + 'dav', + 'devtools', + 'disk', + 'disks', + 'file', + 'filesystem', + 'ftp', + 'hcp', + 'help', + 'ie.http', + 'its', + 'jar', + 'javascript', + 'jnlp', + 'localfs', + 'mk', + 'ms-appinstaller', + 'ms-excel', + 'ms-help', + 'ms-its', + 'ms-msdt', + 'ms-officecmd', + 'ms-powerpoint', + 'ms-settings', + 'ms-word', + 'nfs', + 'nntp', + 'res', + 'search', + 'search-ms', + 'shell', + 'sim-shell', + 'smb', + 'vbscript', + 'view-source', + 'vnd.ms.radio', + 'webdav', + 'x-man-page', +]) +const DENIED_APP_LINK_SCHEME_PREFIXES = ['ms-cxh', 'local+'] as const +const MAX_APP_LINK_LENGTH = 2_048 +/** C0 controls, space, DEL, and C1 controls: never valid in a URL handed to the OS. */ +const URL_CONTROL_CHARACTERS = /[\u0000-\u0020\u007f-\u009f]/ + +function isDeniedAppLinkScheme(scheme: string): boolean { + return ( + scheme.length < 2 || + scheme === 'http' || + scheme === 'https' || + DENIED_APP_LINK_SCHEMES.has(scheme) || + DENIED_APP_LINK_SCHEME_PREFIXES.some((prefix) => scheme.startsWith(prefix)) + ) +} + +/** + * The link to another app a browser page may hand the OS, rebuilt by the URL + * parser, or null when the scheme is denied or the URL is malformed, oversized, + * carries credentials or control characters, or is an unsafe mail link. + */ +export function appLinkTarget(raw: unknown): { url: string; scheme: string } | null { + if (typeof raw !== 'string' || raw.length > MAX_APP_LINK_LENGTH) return null + if (URL_CONTROL_CHARACTERS.test(raw)) return null + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + const scheme = url.protocol.slice(0, -1).toLowerCase() + if (!/^[a-z][a-z0-9+.-]*$/.test(scheme) || isDeniedAppLinkScheme(scheme)) return null + if (url.username || url.password) return null + if (scheme === 'mailto' && !isSafeMailtoUrl(raw)) return null + const rebuilt = url.href + if (rebuilt.length > MAX_APP_LINK_LENGTH || URL_CONTROL_CHARACTERS.test(rebuilt)) return null + return { url: rebuilt, scheme } +} + +/** + * Opens a URL in the system browser, a `mailto:` in the system mail client, or, with + * `appLink`, a validated link to another app, after validation. Every openExternal in the + * app goes through here — menu items, IPC, navigation policy, and browser-tab app links. */ -export async function openExternalSafe(raw: string, allowHttpLocalhost = false): Promise { - if (!isSafeExternalUrl(raw, allowHttpLocalhost)) { +export async function openExternalSafe( + raw: string, + allowHttpLocalhost = false, + { appLink = false }: { appLink?: boolean } = {} +): Promise { + if ( + !isSafeExternalUrl(raw, allowHttpLocalhost) && + !isSafeMailtoUrl(raw) && + !(appLink && appLinkTarget(raw)?.url === raw) + ) { logger.warn('Blocked unsafe external URL', { url: scrubUrl(raw) }) return false } diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts index 93d719e5e9b..e69dbb8c669 100644 --- a/apps/desktop/src/main/security-guards.ts +++ b/apps/desktop/src/main/security-guards.ts @@ -1,6 +1,11 @@ import { createLogger } from '@sim/logger' -import type { WebContents } from 'electron' +import type { WebContents, WebFrameMain } from 'electron' import { app } from 'electron' +import { + handleBrowserLogin, + handleSelectClientCertificate, + requestExternalLaunch, +} from '@/main/browser-agent/page-prompts' import { isAgentWebContents } from '@/main/browser-agent/registry' import { trackInputActivity } from '@/main/input-activity' import { classifyNavigation, openExternalSafe } from '@/main/navigation' @@ -21,14 +26,25 @@ export interface GuardDeps { * Applies the top-level navigation policy to both will-navigate and * will-redirect — OAuth chains can be redirect-injected, so redirects get the * same classifier as direct navigations. + * + * Agent-browser tabs never navigate off http(s). A link to another app (Zoom, + * Teams, mail) becomes a launch request the user decides only when the tab's + * own top-level document initiated it: a navigation a frame initiated, even + * one targeting the top level, is refused, as is the `openExternal` + * permission that backs frame launches. */ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): void { - const handle = (event: { preventDefault(): void }, url: string) => { + const handle = ( + event: { preventDefault(): void; isMainFrame?: boolean; initiator?: WebFrameMain | null }, + url: string + ) => { if (isAgentWebContents(contents)) { const hasAllowedAgentBrowserScheme = /^https?:/i.test(url) if (!hasAllowedAgentBrowserScheme) { event.preventDefault() - logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) }) + if (event.isMainFrame === true) { + requestExternalLaunch(contents, url, event.initiator) + } else logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) }) } return } @@ -69,7 +85,9 @@ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): * Agent-browser tabs swap the classifier for a free-http(s) policy (their job * is browsing arbitrary sites, isolated on their own partition). TLS errors * are always fatal when packaged; self-host private CAs must be - * system-trusted rather than bypassed in-app. + * system-trusted rather than bypassed in-app. HTTP authentication and client + * certificate requests from agent-browser tabs wait on the user; every other + * contents keeps Electron's defaults. */ export function installGlobalGuards(deps: GuardDeps): void { app.on('web-contents-created', (_event, contents) => { @@ -87,6 +105,9 @@ export function installGlobalGuards(deps: GuardDeps): void { attachNavigationGuards(contents, deps) }) + app.on('login', handleBrowserLogin) + app.on('select-client-certificate', handleSelectClientCertificate) + app.on('certificate-error', (event, _webContents, url, error, _certificate, callback) => { event.preventDefault() callback(false) diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index c9334dab154..9aafbf7ff2c 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -37,7 +37,11 @@ import { isResourceTabSelectionShortcut, resourceTabTargetIndex, } from '@/main/resource-shortcuts' -import { readForegroundProcessGroup, signalProcessGroup } from '@/main/terminal/process-group' +import { + type ForegroundProcessGroup, + readForegroundProcessGroup, + signalProcessGroup, +} from '@/main/terminal/process-group' import type { RunLedger } from '@/main/terminal/run-ledger' import { elide, type ShellStartupBounds, TerminalSession } from '@/main/terminal/session' import { @@ -167,7 +171,7 @@ export interface TerminalServiceOptions { } interface TerminalProcessGroups { - foreground(shellPid: number): Promise + foreground(shellPid: number): Promise signal(pgid: number, signal: NodeJS.Signals): void } @@ -176,6 +180,22 @@ const OS_PROCESS_GROUPS: TerminalProcessGroups = { signal: signalProcessGroup, } +/** The terminal work a restart would cut off. */ +export interface TerminalWork { + /** The command line holding each busy terminal's foreground, `''` when unannounced. */ + commands: string[] + /** Terminals whose foreground could not be read, which may be running anything. */ + uncheckedCount: number +} + +async function foregroundGroupId( + groups: TerminalProcessGroups, + shellPid: number +): Promise { + const foreground = await groups.foreground(shellPid) + return foreground.kind === 'group' ? foreground.pgid : null +} + /** * One tool call's Stop: remembered if it arrives before the call's command starts, and handed to * whatever is running once it does. @@ -303,6 +323,32 @@ export class TerminalService { this.cwdTimer = null } + /** + * What each terminal is running. A shell without integration (fish, sh) announces nothing, so + * the OS's foreground process group answers for it; one the OS could not be asked about is + * reported as unchecked rather than idle. + */ + async runningWork(): Promise { + const groups = this.options.processGroups ?? OS_PROCESS_GROUPS + const perSession = await Promise.all( + [...this.sessions.values()].map( + async (session): Promise<{ command: string | null; unchecked: boolean }> => { + if (session.isBusy) return { command: session.foreground ?? '', unchecked: false } + if (session.hasShellIntegration) return { command: null, unchecked: false } + const foreground = await groups.foreground(session.pid) + return { + command: foreground.kind === 'group' ? '' : null, + unchecked: foreground.kind === 'unknown', + } + } + ) + ) + return { + commands: perSession.flatMap(({ command }) => (command === null ? [] : [command])), + uncheckedCount: perSession.filter(({ unchecked }) => unchecked).length, + } + } + getTabs(): TerminalTabsState { if (this.sessions.size === 0) return EMPTY_TABS return { @@ -973,12 +1019,12 @@ export class TerminalService { private async stopCommand(session: TerminalSession, toolCallId: string): Promise { if (session.agentCommandToolCallId !== toolCallId) return const groups = this.options.processGroups ?? OS_PROCESS_GROUPS - const pgid = await groups.foreground(session.pid) + const pgid = await foregroundGroupId(groups, session.pid) if (session.agentCommandToolCallId !== toolCallId) return session.kill('SIGINT') for (const escalation of ['SIGTERM', 'SIGKILL'] as const) { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return - if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return + if (pgid === null || (await foregroundGroupId(groups, session.pid)) !== pgid) return if (session.agentCommandToolCallId !== toolCallId) return logger.info('Stopped command ignored the previous signal; escalating', { toolCallId, diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts new file mode 100644 index 00000000000..efefec57e4b --- /dev/null +++ b/apps/desktop/src/main/terminal/process-group.test.ts @@ -0,0 +1,15 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { describe, expect, it } from 'vitest' +import { readForegroundProcessGroup } from '@/main/terminal/process-group' + +describe.skipIf(process.platform === 'win32')('readForegroundProcessGroup', () => { + it('reads a shell that has already exited as idle, not unknown', async () => { + const child = spawn('true') + await once(child, 'exit') + const exitedPid = child.pid + if (exitedPid === undefined) throw new Error('The child never started') + + await expect(readForegroundProcessGroup(exitedPid)).resolves.toEqual({ kind: 'shell' }) + }) +}) diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index 6e5a4e10a87..4c040383cb2 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -11,11 +11,25 @@ const logger = createLogger('DesktopTerminalProcessGroup') const LOOKUP_TIMEOUT_MS = 2_000 /** - * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the - * shell itself holds the foreground (it is sitting at its prompt). + * Who holds a terminal's foreground: the shell at its prompt (or a shell that has already + * exited), another process group, or `unknown` when the OS could not be asked. */ -export function readForegroundProcessGroup(shellPid: number): Promise { - if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(null) +export type ForegroundProcessGroup = + | { kind: 'shell' } + | { kind: 'group'; pgid: number } + | { kind: 'unknown' } + +const SHELL: ForegroundProcessGroup = { kind: 'shell' } +const UNKNOWN: ForegroundProcessGroup = { kind: 'unknown' } + +/** + * The tty's foreground process group, read from `ps`. A pid `ps` no longer knows is a shell + * that has exited, so nothing can be running in it. Windows has no `ps` or tty process groups, + * so a Windows shell always reads as holding its own foreground. + */ +export function readForegroundProcessGroup(shellPid: number): Promise { + if (process.platform === 'win32') return Promise.resolve(SHELL) + if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(UNKNOWN) return new Promise((resolve) => { let child: ReturnType try { @@ -23,12 +37,12 @@ export function readForegroundProcessGroup(shellPid: number): Promise { + const finish = (value: ForegroundProcessGroup) => { if (settled) return settled = true clearTimeout(timer) @@ -36,15 +50,22 @@ export function readForegroundProcessGroup(shellPid: number): Promise { child.kill('SIGKILL') - finish(null) + finish(UNKNOWN) }, LOOKUP_TIMEOUT_MS) child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.on('error', () => finish(null)) - child.on('close', () => { - const pgid = Number.parseInt(stdout.trim(), 10) - finish(Number.isInteger(pgid) && pgid > 0 && pgid !== shellPid ? pgid : null) + child.on('error', () => finish(UNKNOWN)) + child.on('close', (code) => { + const output = stdout.trim() + if (code !== 0 || !output) { + finish(code === 1 && !output ? SHELL : UNKNOWN) + return + } + const pgid = Number.parseInt(output, 10) + if (!Number.isInteger(pgid)) finish(UNKNOWN) + else if (pgid <= 0 || pgid === shellPid) finish(SHELL) + else finish({ kind: 'group', pgid }) }) }) } diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 22c91489830..8686b023c63 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -18,6 +18,7 @@ import { TerminalService, type TerminalServiceOptions, type TerminalSink, + type TerminalWork, } from '@/main/terminal' import type { RunLedger, RunRecord } from '@/main/terminal/run-ledger' import { recordedRunState, stopRecordedRun } from '@/main/terminal/tmux' @@ -375,6 +376,17 @@ export class TerminalRegistry { return true } + /** What every chat's terminals are running. */ + async runningWork(): Promise { + const perService = await Promise.all( + [...this.entries.values()].map((entry) => entry.service.runningWork()) + ) + return { + commands: perService.flatMap((work) => work.commands), + uncheckedCount: perService.reduce((total, work) => total + work.uncheckedCount, 0), + } + } + /** * Stops every command the agent started in any chat's terminals; the user's own are untouched. * That includes tmux runs no live terminal holds any more: a chat put away, or a previous diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 6ff6a0a53af..32a77f6c7a8 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { sleep } from '@sim/utils/helpers' import { describe, expect, it, vi } from 'vitest' import { TerminalService } from '@/main/terminal' +import type { ForegroundProcessGroup } from '@/main/terminal/process-group' import { createRunLedger } from '@/main/terminal/run-ledger' /** @@ -104,6 +105,9 @@ const { stubSessions } = vi.hoisted(() => ({ clearScrollback(): void /** Whether Ctrl-C ends the running command, as it does for most programs. */ setInterruptible(interruptible: boolean): void + /** Whether the shell announces its commands, as zsh and bash do and fish does not. */ + setShellIntegration(on: boolean): void + readonly pid: number /** Ends the running command the way its process exiting would. */ finishRun(exitCode: number): void /** Returns the run as still going, its command left running, as a wait that ran out does. */ @@ -142,6 +146,7 @@ vi.mock('@/main/terminal/session', async () => { disposed: false, busy: false, interruptible: true, + shellIntegration: true, toolCallId: null as string | null, resolveRun: null as ((result: Record) => void) | null, } @@ -159,6 +164,9 @@ vi.mock('@/main/terminal/session', async () => { setInterruptible: (interruptible: boolean) => { state.interruptible = interruptible }, + setShellIntegration: (on: boolean) => { + state.shellIntegration = on + }, finishRun, handBack: () => { const resolve = state.resolveRun @@ -204,7 +212,9 @@ vi.mock('@/main/terminal/session', async () => { get isBusy() { return state.busy }, - hasShellIntegration: true, + get hasShellIntegration() { + return state.shellIntegration + }, /** Real sessions poll the pty here; a stub's cwd only ever changes on open. */ refreshCwd: async () => {}, dispose: () => { @@ -477,6 +487,40 @@ describe('a shell that ends by itself', () => { }) }) +describe('running work before a restart', () => { + function serviceWithForeground(read: (shellPid: number) => Promise) { + const terminal = new TerminalService({ + loadCwd: () => '/tmp', + processGroups: { foreground: vi.fn(read), signal: vi.fn() }, + }) + const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) + const session = stubSessions.get(activeTerminalId as string) + if (!session) throw new Error('No stub session') + return { terminal, session } + } + + it('asks the OS about a shell that announces nothing, and trusts one that does', async () => { + const { terminal, session } = serviceWithForeground(async () => ({ kind: 'group', pgid: 4242 })) + session.setShellIntegration(false) + await expect(terminal.runningWork()).resolves.toEqual({ commands: [''], uncheckedCount: 0 }) + + session.setShellIntegration(true) + await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 }) + }) + + it('counts a shell at its own prompt as idle', async () => { + const { terminal, session } = serviceWithForeground(async () => ({ kind: 'shell' })) + session.setShellIntegration(false) + await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 }) + }) + + it('reports a terminal whose foreground could not be read as unchecked, never idle', async () => { + const { terminal, session } = serviceWithForeground(async () => ({ kind: 'unknown' })) + session.setShellIntegration(false) + await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 1 }) + }) +}) + describe('stopping a tool call', () => { const COMMAND_GROUP = 4242 @@ -484,7 +528,10 @@ describe('stopping a tool call', () => { /** The tty's foreground group as the OS would report it at each read. */ let foreground: number | null = COMMAND_GROUP const processGroups = { - foreground: vi.fn(async (_shellPid: number) => foreground), + foreground: vi.fn( + async (_shellPid: number): Promise => + foreground === null ? { kind: 'unknown' } : { kind: 'group', pgid: foreground } + ), signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}), } const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups }) @@ -535,6 +582,20 @@ describe('stopping a tool call', () => { await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 143 } }) }) + it('interrupts but never escalates when the foreground cannot be read', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setInterruptible(false) + const { running } = await startRun(terminal, session, 'call-unreadable') + setForeground(null) + + await terminal.cancelTool('call-unreadable') + + expect(session.kill).toHaveBeenCalledWith('SIGINT') + expect(processGroups.signal).not.toHaveBeenCalled() + session.finishRun(0) + await running + }) + it('never signals a different command that took the foreground meanwhile', async () => { const { terminal, session, processGroups, setForeground } = cancellableService() session.setInterruptible(false) diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 5ed6241ccfb..723cc3d38ae 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -112,6 +112,10 @@ describe('initUpdater state machine', () => { probeOriginFeed?: (feedUrl: string) => Promise beforeInstall?: () => Promise setRelaunchPending?: (pending: boolean) => void + /** Work the restart would interrupt, as the in-flight check names it. */ + withRunningWork?: string + /** Overrides the in-flight check, e.g. to make it fail. */ + describeInFlightWork?: () => Promise }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -127,6 +131,9 @@ describe('initUpdater state machine', () => { platform: 'darwin', beforeInstall: options?.beforeInstall, setRelaunchPending: options?.setRelaunchPending, + describeInFlightWork: + options?.describeInFlightWork ?? + (async () => (options?.withRunningWork ? [options.withRunningWork] : [])), }) // Engine selection (signature detection) resolves asynchronously. await vi.advanceTimersByTimeAsync(0) @@ -151,7 +158,7 @@ describe('initUpdater state machine', () => { vi.useRealTimers() }) - it('walks check -> validated download -> ready and installs only after confirmation', async () => { + it('walks check -> validated download -> ready and installs on one click when nothing is running', async () => { const { handle, states } = await createUpdater() expect(handle.getState()).toEqual({ status: 'idle' }) @@ -183,21 +190,67 @@ describe('initUpdater state machine', () => { handle.install() await vi.advanceTimersByTimeAsync(0) + expect(dialog.showMessageBox).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('asks once, naming the running work, and keeps the update ready on Not Now', async () => { + const setRelaunchPending = vi.fn() + const { handle } = await createUpdater({ + setRelaunchPending, + withRunningWork: 'claude is still running in a terminal.', + }) + await stageUpdate(handle, '2.0.0') + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1) expect(dialog.showMessageBox).toHaveBeenCalledWith( expect.objectContaining({ - buttons: ['Later', 'Restart and update'], + message: expect.stringContaining('claude is still running in a terminal.'), + buttons: ['Restart and Update', 'Not Now'], defaultId: 0, - cancelId: 0, + cancelId: 1, }) ) expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(setRelaunchPending).not.toHaveBeenCalled() + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + }) - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ - response: 1, - checkboxChecked: false, + it('asks before restarting when the check for running work fails', async () => { + const { handle } = await createUpdater({ + describeInFlightWork: async () => { + throw new Error('terminal probe failed') + }, }) + await stageUpdate(handle, '2.0.0') + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + handle.install() await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Sim couldn't check whether restarting would interrupt any work.", + }) + ) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + }) + + it('installs a required update without offering to wait for running work', async () => { + const { handle } = await createUpdater({ + withRunningWork: 'claude is still running in a terminal.', + }) + await stageUpdate(handle, '2.0.0') + + handle.install({ mandatory: true }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).not.toHaveBeenCalled() expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -212,7 +265,9 @@ describe('initUpdater state machine', () => { resolveConfirmation = resolve }) ) - const { handle } = await createUpdater() + const { handle } = await createUpdater({ + withRunningWork: 'An agent is still working on this computer.', + }) handle.check() await vi.advanceTimersByTimeAsync(0) @@ -222,11 +277,12 @@ describe('initUpdater state machine', () => { vi.mocked(dialog.showMessageBox).mockClear() handle.install() handle.install() + await vi.advanceTimersByTimeAsync(0) expect(dialog.showMessageBox).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - resolveConfirmation({ response: 1, checkboxChecked: false }) + resolveConfirmation({ response: 0, checkboxChecked: false }) await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) @@ -260,10 +316,6 @@ describe('initUpdater state machine', () => { handle.check() emit('update-downloaded', { version: '2.0.0' }) emitSquirrel('update-downloaded') - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ - response: 1, - checkboxChecked: false, - }) handle.install() await vi.advanceTimersByTimeAsync(0) @@ -292,10 +344,6 @@ describe('initUpdater state machine', () => { emit('update-downloaded', { version: '2.0.0' }) emitSquirrel('update-downloaded') await vi.advanceTimersByTimeAsync(10_000) - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ - response: 1, - checkboxChecked: false, - }) handle.install() await vi.advanceTimersByTimeAsync(0) @@ -328,7 +376,6 @@ describe('initUpdater state machine', () => { }) ) await vi.advanceTimersByTimeAsync(10_000) - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) handle.install() await vi.advanceTimersByTimeAsync(0) @@ -343,10 +390,6 @@ describe('initUpdater state machine', () => { it('bypasses renderer unload guards only after teardown succeeds', async () => { const setRelaunchPending = vi.fn() - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ - response: 1, - checkboxChecked: false, - }) const { handle } = await createUpdater({ beforeInstall: async () => {}, setRelaunchPending, @@ -446,7 +489,9 @@ describe('initUpdater state machine', () => { () => { throw new Error('Restart confirmation did not initialize') } - const { handle } = await createUpdater() + const { handle } = await createUpdater({ + withRunningWork: 'A window has unsaved changes.', + }) await stageUpdate(handle, '2.0.0') await vi.advanceTimersByTimeAsync(10_000) @@ -457,11 +502,12 @@ describe('initUpdater state machine', () => { }) ) handle.install() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.1.0' }) emit('update-downloaded', { version: '2.1.0' }) emitSquirrel('update-downloaded') expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) - resolveConfirmation({ response: 1, checkboxChecked: false }) + resolveConfirmation({ response: 0, checkboxChecked: false }) await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) @@ -477,7 +523,6 @@ describe('initUpdater state machine', () => { }) await stageUpdate(handle, '2.0.0') await vi.advanceTimersByTimeAsync(10_000) - vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) handle.install() await vi.advanceTimersByTimeAsync(0) @@ -826,6 +871,41 @@ describe('checkForUpdatesInteractive', () => { vi.useRealTimers() }) + function readyHandle(): UpdaterHandle { + return { + setAutoDownload: () => {}, + getState: () => ({ status: 'ready', version: '9.9.9' }), + check: vi.fn(), + install: vi.fn(), + onState: () => () => {}, + } + } + + it('asks before restarting when the check finds an update already staged', async () => { + const handle = readyHandle() + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sim 9.9.9 is ready to install', + buttons: ['Restart to Update', 'Later'], + }) + ) + expect(handle.install).not.toHaveBeenCalled() + }) + + it('installs a staged update only once the user chooses Restart to Update', async () => { + const handle = readyHandle() + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(handle.install).toHaveBeenCalledTimes(1) + }) + it('fails a hung interactive check after twelve seconds instead of waiting thirty', async () => { const handle: UpdaterHandle = { setAutoDownload: () => {}, diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 05e786dd541..1c084971a27 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -251,12 +251,22 @@ export interface UpdaterDeps { platform?: NodeJS.Platform /** Flushes desktop-owned state before Squirrel terminates the process. */ beforeInstall?: () => Promise - /** Bypasses renderer unload guards only after the user confirms a relaunch. */ + /** Bypasses renderer unload guards once a relaunch is committed. */ setRelaunchPending?: (pending: boolean) => void + /** + * Names the work a restart would interrupt (running commands, agent + * actions, unsaved edits). A restart asks first only when this is non-empty. + */ + describeInFlightWork?: () => Promise /** Persists the natively staged version so the next process can verify installation. */ installStatePath?: string } +interface UpdateInstallOptions { + /** The shell is below the server's minimum version, so waiting is not an option. */ + mandatory?: boolean +} + export interface UpdaterHandle { setAutoDownload(enabled: boolean): void /** Current pipeline state for the renderer update UI. */ @@ -270,7 +280,7 @@ export interface UpdaterHandle { * Quit and install a downloaded update (`ready`), or open the manual * download for an `available` update on a shell that can't self-update. */ - install(): void + install(options?: UpdateInstallOptions): void /** Main-process state subscription (menu feedback). Returns unsubscribe. */ onState(callback: (state: DesktopUpdateState) => void): () => void } @@ -338,7 +348,7 @@ interface UpdateEngine { /** `interactive` requests must always publish a terminal state for their waiting UI. */ check(interactive?: boolean): void advance(): void - install(): void + install(options: { mandatory: boolean }): void setAutoDownload(enabled: boolean): void } @@ -470,35 +480,48 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) } - const confirmAndInstall = () => { + /** Installs at once unless the restart would interrupt nameable work; then asks once. */ + const installUnlessInterrupting = async ({ mandatory }: { mandatory: boolean }) => { if (installInFlight || installConfirmationInFlight || state.status !== 'ready') return installConfirmationInFlight = true - const version = state.version - const options: Electron.MessageBoxOptions = { - type: 'question', - buttons: ['Later', 'Restart and update'], - defaultId: 0, - cancelId: 0, - message: version ? `Restart to install Sim ${version}?` : 'Restart to update Sim?', - detail: - 'Sim will close all app windows while it updates. Running terminal commands, browser activity, downloads, uploads, and unsaved edits may be interrupted. Choose Later to install the update the next time you quit Sim.', - } - const win = deps.getWindow() - const confirmation = win ? showShellDialog(win, options) : showShellDialog(options) - void confirmation - .then(({ response }) => { - if (response === 1 && state.status === 'ready') { - quitAndInstall() + try { + let work: string[] = [] + if (!mandatory) { + try { + work = (await deps.describeInFlightWork?.()) ?? [] + } catch (error) { + logger.warn('Could not check for work a restart would interrupt', { + message: getErrorMessage(error, 'unknown'), + }) + work = ["Sim couldn't check whether restarting would interrupt any work."] } + } + if (work.length > 0) { + const version = state.version + const options: Electron.MessageBoxOptions = { + type: 'warning', + title: version ? `Restart to install Sim ${version}?` : 'Restart to update Sim?', + message: work.join(' '), + detail: + 'Restarting now interrupts this work. Choose Not Now to finish first; the update also installs the next time you quit Sim.', + buttons: ['Restart and Update', 'Not Now'], + defaultId: 0, + cancelId: 1, + } + const win = deps.getWindow() + const { response } = await (win + ? showShellDialog(win, options) + : showShellDialog(options)) + if (response !== 0) return + } + if (state.status === 'ready') quitAndInstall() + } catch (error) { + logger.warn('Could not show the restart prompt for work in progress', { + message: getErrorMessage(error, 'unknown'), }) - .catch((error) => { - logger.warn('Could not show update restart confirmation', { - message: getErrorMessage(error, 'unknown'), - }) - }) - .finally(() => { - installConfirmationInFlight = false - }) + } finally { + installConfirmationInFlight = false + } } let activeProbeId: number | null = null @@ -838,8 +861,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { advance() { if (acceptedUpdateVersion !== null) download(acceptedUpdateVersion) }, - install() { - confirmAndInstall() + install(options) { + void installUnlessInterrupting(options) }, setAutoDownload(enabled) { autoDownloadEnabled = enabled @@ -1017,12 +1040,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } engine.check(true) }, - install() { + install({ mandatory = false } = {}) { if (!engine) { return } if (state.status === 'ready') { - engine.install() + engine.install({ mandatory }) return } if (state.status === 'available' && state.manual) { @@ -1094,11 +1117,23 @@ export function checkForUpdatesInteractive( void showDialog({ type: 'info', message: state.version ? `Downloading Sim ${state.version}…` : 'Downloading update…', - detail: 'You will be prompted to restart when it is ready.', + detail: 'When it finishes, choose Restart to update in Sim, or quit Sim to install it.', }) return case 'ready': - handle.install() + // The user asked to check, not to restart: confirm before closing every window. + void showDialog({ + type: 'info', + buttons: ['Restart to Update', 'Later'], + defaultId: 0, + cancelId: 1, + message: state.version + ? `Sim ${state.version} is ready to install` + : 'An update is ready to install', + detail: 'Restart Sim now to finish updating, or it installs the next time you quit.', + }).then(({ response }) => { + if (response === 0) handle.install() + }) return case 'error': void showDialog({ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 811b07561f4..b59f2e933fa 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -17,6 +17,7 @@ import type { import type { BrowserAddToChatPayload, BrowserChromeImportResult, + BrowserContinueSignInResult, BrowserCredentialConflictPolicy, BrowserCredentialMetadata, BrowserDownloadsState, @@ -24,6 +25,7 @@ import type { BrowserImportProfile, BrowserImportResult, BrowserPasswordImportResult, + BrowserPendingSignIn, BrowserSiteInfo, BrowserToolbarCommand, ComputerUseActivity, @@ -238,8 +240,8 @@ const api: SimDesktopApi = { check: (): void => { ipcRenderer.send('desktop:updates:check') }, - install: (): void => { - ipcRenderer.send('desktop:updates:install') + install: (options?: { mandatory?: boolean }): void => { + ipcRenderer.send('desktop:updates:install', { mandatory: options?.mandatory === true }) }, onState: (callback: (state: DesktopUpdateState) => void): (() => void) => { const listener = (_event: unknown, state: DesktopUpdateState) => callback(state) @@ -439,6 +441,17 @@ const api: SimDesktopApi = { policy?: BrowserCredentialConflictPolicy ): Promise => ipcRenderer.invoke('browser-import:all', profileId, policy), + continueSignIn: ( + profileId: string, + scopeId: string + ): Promise => + ipcRenderer.invoke('browser-import:continue-sign-in', profileId, scopeId), + bringSignInBack: (scopeId: string): Promise => + ipcRenderer.invoke('browser-import:bring-sign-in-back', scopeId), + cancelSignIn: (scopeId: string): Promise => + ipcRenderer.invoke('browser-import:cancel-sign-in', scopeId), + getPendingSignIn: (scopeId: string): Promise => + ipcRenderer.invoke('browser-import:pending-sign-in', scopeId), }, } : {}), diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index c0f180897d0..105aa55f117 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -107,6 +107,11 @@ export const session = { fromPartition: vi.fn(), } +/** Resolves a frame to its WebContents; tests return the contents they registered. */ +export const webContents = { + fromFrame: vi.fn(), +} + export const protocol = { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn(), diff --git a/apps/docs/DOMAIN_MOVE.md b/apps/docs/DOMAIN_MOVE.md index bbd479bcc00..6085e1690fd 100644 --- a/apps/docs/DOMAIN_MOVE.md +++ b/apps/docs/DOMAIN_MOVE.md @@ -2,6 +2,8 @@ The site currently targets `docs.sim.ai`. A move to `sim.ai/docs` is separate infrastructure work, not completed by the docs redesign. Changing `NEXT_PUBLIC_DOCS_URL` alone is insufficient. +The platform's temporary 307 redirects send `/docs` to the introduction and `/docs/*` to the matching path on `docs.sim.ai`, preserving query parameters. Remove these outgoing redirects when mounting the docs at `/docs`; they are not the routing layer for the future move. + ## Required changes - Build Next.js with `basePath: '/docs'`; an asset prefix alone does not mount the app. Keep canonical catalog IDs such as `/introduction` independent of the public prefix. diff --git a/apps/docs/README.md b/apps/docs/README.md index 4733ee045c2..59428b42b94 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -29,3 +29,16 @@ The [machine access guide](MACHINE_ACCESS.md) documents Markdown, JSON, MCP, sea From the repository root, run the required lint, type, audit, and test gates in the development guidelines. From this directory, `bun run type-check` compiles MDX and checks the docs workspace, and `bun run build` validates the production site. Inspect affected page types on desktop and a narrow viewport, and check internal links and existing anchors after heading changes. The docs HTTP workflow also checks sidebar navigation, API language switching, and copying Markdown on desktop and mobile layouts. It uploads screenshots and a JSON report with the HTTP regression artifacts. These checks run against the built site in CI; no visitor telemetry is collected. Use Vercel's build logs and usage reports to compare deployment cost and duration. + +## Reader feedback + +Votes are submitted through `/api/feedback` to an authenticated Sim webhook. Configure +`DOCS_FEEDBACK_WEBHOOK_URL` and `DOCS_FEEDBACK_WEBHOOK_TOKEN` only on the server. The +endpoint returns unavailable when either is absent. Limit POST requests to this path +at the deployment edge before enabling it (10 requests per IP per minute). + +The workflow receives `submission_id`, `page`, `helpful`, `comment`, and `received_at`. +Use `submission_id` as the webhook deduplication field and a unique table column for +upsert. Retries of an unchanged submission reuse its ID. The acknowledgment confirms +queue acceptance; workflow failures remain visible in Sim execution logs. No email, +IP address, or user identity is included in the stored feedback payload. diff --git a/apps/docs/app/api/feedback/route.ts b/apps/docs/app/api/feedback/route.ts new file mode 100644 index 00000000000..7f156870d5e --- /dev/null +++ b/apps/docs/app/api/feedback/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { getPage } from '@/lib/content/catalog' +import { pagePathSchema } from '@/lib/http/pages' + +const logger = createLogger('DocsFeedback') +const feedbackSchema = z + .object({ + submissionId: z.uuid(), + page: pagePathSchema, + helpful: z.boolean(), + comment: z.string().trim().max(2000), + }) + .strict() +const MAX_BODY_BYTES = 12_288 + +export async function POST(request: Request) { + const headers = { 'Cache-Control': 'no-store' } + const fail = (error: string, status: number) => Response.json({ error }, { status, headers }) + const requestUrl = new URL(request.url) + const host = request.headers.get('host') + if (!host || request.headers.get('origin') !== `${requestUrl.protocol}//${host}`) { + return fail('Feedback must be submitted from this site.', 403) + } + if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') { + return fail('Expected JSON.', 415) + } + const reader = request.body?.getReader() + if (!reader) return fail('Feedback is required.', 400) + let text = '' + let bytes = 0 + const decoder = new TextDecoder() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > MAX_BODY_BYTES) { + await reader.cancel() + return fail('Feedback is too long.', 413) + } + text += decoder.decode(chunk.value, { stream: true }) + } + text += decoder.decode() + } catch { + return fail('Could not read feedback.', 400) + } finally { + reader.releaseLock() + } + let input: z.infer + try { + input = feedbackSchema.parse(JSON.parse(text)) + } catch { + return fail('Check your feedback and try again.', 400) + } + const page = getPage(input.page.replace(/\/$/, '') || '/') + if (!page || page.url === '/') return fail('Documentation page not found.', 404) + const endpoint = process.env.DOCS_FEEDBACK_WEBHOOK_URL + const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN + if (!endpoint || !token) return fail('Feedback is temporarily unavailable.', 503) + try { + const url = new URL(endpoint) + if (url.protocol !== 'https:') throw new Error('Invalid feedback configuration') + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + submission_id: input.submissionId, + page: page.url, + helpful: input.helpful, + comment: input.comment, + received_at: new Date().toISOString(), + }), + signal: AbortSignal.timeout(15_000), + redirect: 'error', + }) + if (!response.ok) { + logger.warn('Feedback intake rejected submission', { status: response.status }) + return fail('Could not send feedback. Please try again.', 502) + } + const receipt: unknown = await response.json() + if ( + !receipt || + typeof receipt !== 'object' || + !('message' in receipt) || + receipt.message !== 'Webhook processed' + ) { + return fail('Could not confirm feedback. Please try again.', 502) + } + return Response.json({ accepted: true }, { status: 202, headers }) + } catch { + logger.warn('Feedback intake unavailable') + return fail('Could not send feedback. Please try again.', 503) + } +} diff --git a/apps/docs/components/docs/article.tsx b/apps/docs/components/docs/article.tsx index 01cc048e327..09b9e35a0f3 100644 --- a/apps/docs/components/docs/article.tsx +++ b/apps/docs/components/docs/article.tsx @@ -1,13 +1,13 @@ import type { ReactNode } from 'react' -import { ArrowLeft, ArrowRight, SquareArrowUpRight, ThumbsDown, ThumbsUp } from '@sim/emcn/icons' +import { ArrowLeft, ArrowRight, SquareArrowUpRight } from '@sim/emcn/icons' import Link from 'next/link' import { ContentInteractions } from '@/components/docs/content-interactions' +import { Feedback } from '@/components/docs/feedback' import { PageActions } from '@/components/docs/page-actions' import { IntegrationIcon } from '@/components/ui/integration-icon' import { getNavigation, getNeighbours } from '@/lib/content/catalog' import { findNavigationGroup } from '@/lib/content/navigation' import type { DocsPage } from '@/lib/content/types' -import { DOCS_BASE_URL } from '@/lib/urls' interface ArticleProps { page: DocsPage @@ -24,8 +24,6 @@ export function Article({ page, children, aside }: ArticleProps) { ? `apps/sim/lib/api/contracts/v2` : `apps/docs/content/docs/${page.sourcePath}` const editUrl = `https://github.com/simstudioai/sim/${page.operation ? 'tree' : 'edit'}/staging/${source}` - const feedbackUrl = (helpful: boolean) => - `https://github.com/simstudioai/sim/issues/new?${new URLSearchParams({ title: `Docs feedback: ${page.title}`, body: `Page: ${DOCS_BASE_URL}${page.url}\n\n${helpful ? 'What helped:' : 'What could be improved:'}\n` })}` return (
{children}
-
- Was this helpful? - - - - - - -
+ Suggest an edit diff --git a/apps/docs/components/docs/feedback.tsx b/apps/docs/components/docs/feedback.tsx new file mode 100644 index 00000000000..4b5448bf3cd --- /dev/null +++ b/apps/docs/components/docs/feedback.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useRef, useState } from 'react' +import { ThumbsDown, ThumbsUp } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' +import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query' +import { DocsButton } from '@/components/docs/primitives' + +interface FeedbackProps { + page: string +} + +export function Feedback({ page }: FeedbackProps) { + const [client] = useState(() => new QueryClient()) + return ( + + + + ) +} + +function FeedbackForm({ page }: FeedbackProps) { + const [helpful, setHelpful] = useState(null) + const [comment, setComment] = useState('') + const pendingSubmission = useRef<{ + submissionId: string + helpful: boolean + comment: string + } | null>(null) + const submission = useMutation({ + retry: false, + mutationFn: async (payload: { submissionId: string; helpful: boolean; comment: string }) => { + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, page }), + signal: AbortSignal.timeout(20_000), + }) + if (!response.ok) + throw new Error( + response.status === 429 + ? 'Too many submissions. Please try again in a minute.' + : 'Could not send feedback. Please try again.' + ) + const result: unknown = await response.json() + if ( + !result || + typeof result !== 'object' || + !('accepted' in result) || + result.accepted !== true + ) { + throw new Error('Could not confirm feedback. Please try again.') + } + }, + }) + function submit(vote: boolean) { + if (submission.isPending) return + const text = vote ? '' : comment.trim() + if ( + !pendingSubmission.current || + pendingSubmission.current.helpful !== vote || + pendingSubmission.current.comment !== text + ) { + pendingSubmission.current = { submissionId: generateId(), helpful: vote, comment: text } + } + submission.mutate(pendingSubmission.current) + } + if (submission.isSuccess) return

Thanks for your feedback.

+ return ( +
{ + event.preventDefault() + if (helpful !== null && !submission.isPending) submit(helpful) + }} + > +
+ Was this helpful? + { + setHelpful(true) + submission.reset() + submit(true) + }} + > + + + { + setHelpful(false) + submission.reset() + }} + > + + +
+ {helpful === false && ( +
+ +