From effd3ce5346708cf368a05e8804e4f07619be866 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:28:44 -0700 Subject: [PATCH 1/3] feat(desktop): continue a passkey sign-in in your browser and bring the session back - From an agent browser tab, open the page in a chosen Chrome-family profile, finish the sign-in there, then bring that site's cookies back - Per-site import scoped to the page's registrable domain (tldts, private suffixes respected), filtered in SQL and checked per row before decryption - One pending handoff per chat in main; a remounted panel restores its Bring session back notice - Subtle hint on known sign-in hosts; hidden on older shells, off macOS, or with no Chromium browser installed --- .github/workflows/desktop-e2e.yml | 2 + apps/desktop/README.md | 5 + apps/desktop/e2e-durations.json | 1 + apps/desktop/e2e/continue-sign-in.spec.ts | 179 ++++++++++++++++ apps/desktop/package.json | 1 + .../src/main/browser-agent/known-sessions.ts | 3 +- .../src/main/browser-import/browser-launch.ts | 25 +++ .../main/browser-import/browser-sources.ts | 9 + .../browser-import/chromium-cookies.test.ts | 51 ++++- .../main/browser-import/chromium-cookies.ts | 48 ++++- .../browser-import/import-service.test.ts | 20 ++ .../src/main/browser-import/import-service.ts | 19 +- .../src/main/browser-import/index.test.ts | 191 ++++++++++++++++++ apps/desktop/src/main/browser-import/index.ts | 82 +++++++- .../main/browser-import/site-scope.test.ts | 76 +++++++ .../src/main/browser-import/site-scope.ts | 45 +++++ .../src/main/browser-import/sqlite-source.ts | 17 +- apps/desktop/src/main/ipc.test.ts | 153 ++++++++++++++ apps/desktop/src/main/ipc.ts | 77 +++++++ apps/desktop/src/preload/index.ts | 13 ++ .../browser-continue-sign-in.tsx | 106 ++++++++++ .../browser-session/browser-notice.tsx | 19 ++ .../browser-session/browser-session.tsx | 11 + .../browser-session/browser-theme-notice.tsx | 19 +- .../browser-session/sign-in-hosts.test.ts | 44 ++++ .../browser-session/sign-in-hosts.ts | 55 +++++ .../browser-session/use-continue-sign-in.ts | 119 +++++++++++ .../browser-import/browser-import-dialog.tsx | 46 ++--- apps/sim/hooks/queries/browser-import.ts | 92 +++++++++ bun.lock | 1 + packages/desktop-bridge/src/index.ts | 41 ++++ 31 files changed, 1497 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/e2e/continue-sign-in.spec.ts create mode 100644 apps/desktop/src/main/browser-import/browser-launch.ts create mode 100644 apps/desktop/src/main/browser-import/index.test.ts create mode 100644 apps/desktop/src/main/browser-import/site-scope.test.ts create mode 100644 apps/desktop/src/main/browser-import/site-scope.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts create mode 100644 apps/sim/hooks/queries/browser-import.ts 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/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/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/continue-sign-in.spec.ts b/apps/desktop/e2e/continue-sign-in.spec.ts new file mode 100644 index 00000000000..c1954da5736 --- /dev/null +++ b/apps/desktop/e2e/continue-sign-in.spec.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer, type Server } 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, test } from '@playwright/test' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { recordCheck } from './check-report' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'continue-sign-in-fixture' + +/** + * "Continue in Chrome" through the real preload, IPC table, and browser session. + * + * The shell runs with an empty home directory, so no browser profile exists and + * nothing here can launch the developer's browser or reach their Keychain. That + * is also the unsupported case the web app must hide the feature for. The + * import-back half against a real Chrome profile needs a Keychain item and is + * covered by the importer's fixture-database tests instead. + */ +test.describe('continue a sign-in in another browser', () => { + let server: Server + let origin: string + let site: string + let home: string + let userData: string + let app: ElectronApplication | undefined + + test.beforeAll(async () => { + server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/api/auth/get-session') { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) + ) + return + } + if (path.startsWith('/api/')) { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{}') + return + } + if (request.headers.host?.startsWith('localhost')) { + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('Sign in

Sign in

') + return + } + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/', + }) + response.end(`Sim fixture

Continue 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/package.json b/apps/desktop/package.json index 92826c3aec4..be7628bbb3d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -43,6 +43,7 @@ "electron-updater": "6.8.9", "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/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-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..db943d75958 --- /dev/null +++ b/apps/desktop/src/main/browser-import/index.test.ts @@ -0,0 +1,191 @@ +import { 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, continueSignIn, getPendingSignIn } = await import( + '@/main/browser-import' +) + +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.clearAllMocks() + vi.mocked(listAllBrowserProfiles).mockResolvedValue([PROFILE]) + vi.mocked(openInBrowserProfile).mockResolvedValue() + readsCookies(2) + cancelSignIn('chat-a') + cancelSignIn('chat-b') +}) + +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 }), + }) + 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 } }) + + 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() + }) +}) diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts index 299de7bcceb..918d11236c6 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,76 @@ 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 +} + +/** At most one per chat: continuing another page replaces the previous handoff. */ +const handoffsByScope = new Map() + +function pendingSignIn({ profile, scope }: SignInHandoff): BrowserPendingSignIn { + return { site: scope.domain, browserLabel: profile.source.label } +} + +/** + * 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. + */ +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 profiles = await listAllBrowserProfiles().catch(() => []) + const profile = profiles.find(({ id }) => id === request.profileId) + if (!profile?.cookiesPath) return { error: 'chrome-not-found' } + try { + await openInBrowserProfile(profile.source.bundleId, profile.directory, request.url) + } catch { + return { error: 'launch-failed' } + } + const handoff = { profile, tabId: request.tabId, url: request.url, scope } + handoffsByScope.set(scopeId, handoff) + return { pending: pendingSignIn(handoff) } +} + +export function getPendingSignIn(scopeId: string): BrowserPendingSignIn | null { + const handoff = handoffsByScope.get(scopeId) + return handoff ? pendingSignIn(handoff) : null +} + +export function cancelSignIn(scopeId: string): void { + handoffsByScope.delete(scopeId) +} + +/** + * 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. + */ +export async function bringSignInBack( + scopeId: string +): Promise<{ result: BrowserImportResult; handoff?: { tabId: string; url: string } }> { + const handoff = handoffsByScope.get(scopeId) + if (!handoff) return { result: cookieFailure('unknown') } + const result = await runCookieImport( + handoff.profile.id, + { ...deps(), listProfiles: async () => [handoff.profile] }, + handoff.scope + ) + if (result.cookiesImported > 0 && handoffsByScope.get(scopeId) === handoff) { + handoffsByScope.delete(scopeId) + } + return { result, handoff } +} 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/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index aeece41f983..fe658ee67f8 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -20,6 +20,15 @@ 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' }, + })), + cancelSignIn: vi.fn(), + getPendingSignIn: vi.fn(() => null), })) vi.mock('@/main/browser-search/suggestions', () => ({ @@ -135,6 +144,8 @@ import { revealCredential, } from '@/main/browser-credentials' import { + bringSignInBack, + continueSignIn, importChromeCookies, importChromePasswords, listChromeImportProfiles, @@ -1164,6 +1175,148 @@ 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 nothing came back', async () => { + vi.mocked(bringSignInBack).mockResolvedValueOnce({ + result: { cookiesImported: 0, cookiesSkipped: 0 }, + handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' }, + }) + + 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 68727596dff..d0e3678bb9c 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,6 +80,10 @@ import { revealCredential, } from '@/main/browser-credentials' import { + bringSignInBack, + cancelSignIn, + continueSignIn, + getPendingSignIn, importChromeCookies, importChromeData, importChromePasswords, @@ -1513,6 +1518,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 } = await bringSignInBack(scopeId) + if (!handoff || 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/preload/index.ts b/apps/desktop/src/preload/index.ts index 03acbf2a5be..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, @@ -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/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx new file mode 100644 index 00000000000..58d3b3f2b72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx @@ -0,0 +1,106 @@ +'use client' + +import { + Chip, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@sim/emcn' +import { X } from '@sim/emcn/icons' +import { BrowserNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice' +import type { ContinueSignInController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in' + +interface BrowserContinueSignInMenuItemsProps { + controller: ContinueSignInController + /** Wraps an action so it runs once the menu has closed. */ + afterClose: (action: () => void) => () => void +} + +/** "Continue in Chrome", or a picker when several browser profiles can take the page. */ +export function BrowserContinueSignInMenuItems({ + controller, + afterClose, +}: BrowserContinueSignInMenuItemsProps) { + const { canContinue, profiles, continueIn } = controller + if (!canContinue) return null + + return ( + <> + + {profiles.length === 1 ? ( + void continueIn(profiles[0]))}> + Continue in {profiles[0].label} + + ) : ( + + Continue in Browser + + {profiles.map((profile) => ( + void continueIn(profile))} + > + {profile.label} + + ))} + + + )} + + ) +} + +interface BrowserContinueSignInNoticeProps { + controller: ContinueSignInController +} + +/** Asks to bring a pending sign-in back, or suggests continuing on a known sign-in page. */ +export function BrowserContinueSignInNotice({ controller }: BrowserContinueSignInNoticeProps) { + const { + pending, + starting, + importing, + hintVisible, + profiles, + continueIn, + bringBack, + cancel, + dismissHint, + } = controller + + if (pending) { + return ( + + void bringBack()}> + {importing ? 'Bringing back…' : 'Bring session back'} + + + Cancel + + + } + /> + ) + } + + if (!hintVisible) return null + const [profile] = profiles + return ( + + void continueIn(profile)}> + Continue in {profile.label} + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx new file mode 100644 index 00000000000..7bda8db6892 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' +import { Banner } from '@sim/emcn' + +interface BrowserNoticeProps { + message: ReactNode + actions: ReactNode +} + +/** A one-line, non-modal notice docked under the browser toolbar. */ +export function BrowserNotice({ message, actions }: BrowserNoticeProps) { + return ( + +
+

{message}

+
{actions}
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 786c75934aa..511e39d9825 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -66,6 +66,10 @@ import { } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' import { addMothershipContext } from '@/lib/mothership/events' +import { + BrowserContinueSignInMenuItems, + BrowserContinueSignInNotice, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in' import { BrowserDownloads } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads' import { BrowserFindBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar' import { BrowserLoadingBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-loading-bar' @@ -90,6 +94,7 @@ import { type OmniboxSuggestion, type UrlSuggestion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions' +import { useContinueSignIn } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in' import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items' import { useDebounce } from '@/hooks/use-debounce' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -444,6 +449,7 @@ export function BrowserSession({ const [importOpen, setImportOpen] = useState(false) const [importVersion, setImportVersion] = useState(0) const canImport = Boolean(getDesktopBridge()?.browserImport?.importFromChrome) + const continueSignIn = useContinueSignIn({ scopeId, pageUrl: pageState?.url }) const mediaPermissionRequest = pageState?.mediaPermissionRequest const sitePermissionRequest = pageState?.sitePermissionRequest const permissionRequest = sitePermissionRequest ?? mediaPermissionRequest @@ -1299,6 +1305,10 @@ export function BrowserSession({ Find in Page ⌘F + sendBrowserPanelAction('zoom-in', {}, scopeId))} @@ -1339,6 +1349,7 @@ export function BrowserSession({ {themeNoticeVersion > 0 && ( )} + {/* Host area: the real page is overlaid exactly on this rect. */} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx index 8df491836cb..9ef343f9513 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx @@ -1,9 +1,10 @@ 'use client' import { useState } from 'react' -import { Banner, Chip } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { sendBrowserPanelAction } from '@/lib/browser-agent/transport' +import { BrowserNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice' interface BrowserThemeNoticeProps { scopeId: string @@ -16,16 +17,14 @@ export function BrowserThemeNotice({ scopeId }: BrowserThemeNoticeProps) { if (!visible) return null return ( - -
-

- Some sites apply theme changes after a reload. -

-
+ sendBrowserPanelAction('reload', {}, scopeId)}>Reload page setVisible(false)} /> -
-
-
+ + } + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts new file mode 100644 index 00000000000..0a3ad3527a1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + parseHttpsPage, + signInHintHost, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts' + +function hintFor(url: string | undefined): string | null { + const page = parseHttpsPage(url) + return page ? signInHintHost(page) : null +} + +describe('signInHintHost', () => { + it.each([ + 'https://accounts.google.com/v3/signin/identifier', + 'https://signin.aws.amazon.com/oauth', + 'https://us-east-1.signin.aws.amazon.com/oauth', + 'https://d-1234567890.awsapps.com/start', + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + 'https://login.live.com/login.srf', + 'https://acme.okta.com/app/UserHome', + 'https://acme.oktapreview.com/', + 'https://github.com/login/oauth/authorize?client_id=x', + 'https://github.com/sessions/two-factor/webauthn', + 'https://appleid.apple.com/sign-in', + 'https://acme.us.auth0.com/u/login', + ])('offers the hint on %s', (url) => { + expect(hintFor(url)).toBe(new URL(url).hostname) + }) + + it.each([ + ['a lookalike that only starts with the host', 'https://accounts.google.com.evil.com/'], + ['a lookalike that only ends with the suffix', 'https://evil-okta.com/'], + ['the bare wildcard suffix', 'https://okta.com/'], + ['the same host over plain http', 'http://accounts.google.com/'], + ['a GitHub page that is not a sign-in', 'https://github.com/simstudioai/sim'], + ['a GitHub path that only starts with login', 'https://github.com/loginx'], + ['an ordinary site', 'https://example.com/login'], + ['a blank tab', 'about:blank'], + ['nothing', undefined], + ['garbage', 'not a url'], + ])('stays quiet on %s', (_label, url) => { + expect(hintFor(url)).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts new file mode 100644 index 00000000000..2ccd4238aa7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts @@ -0,0 +1,55 @@ +interface SignInHost { + host: string + /** Match the host's subdomains instead of the host itself. */ + subdomains?: true + /** Restrict to these path prefixes; omit for the whole host. */ + paths?: readonly string[] +} + +/** + * Sign-in pages that commonly offer a passkey or security key the built-in + * browser cannot complete. On these the browser panel suggests finishing in + * the user's own browser. A hint only: the browser menu offers the same action + * on every https page, so a missing host costs a suggestion, not the feature. + */ +const SIGN_IN_HOSTS: readonly SignInHost[] = [ + { host: 'accounts.google.com' }, + { host: 'signin.aws.amazon.com' }, + { host: 'signin.aws.amazon.com', subdomains: true }, + { host: 'awsapps.com', subdomains: true }, + { host: 'login.microsoftonline.com' }, + { host: 'login.live.com' }, + { host: 'okta.com', subdomains: true }, + { host: 'oktapreview.com', subdomains: true }, + { host: 'github.com', paths: ['/login', '/sessions/two-factor'] }, + { host: 'appleid.apple.com' }, + { host: 'auth0.com', subdomains: true }, +] + +/** The page as a URL when it is https, the only kind that can be continued elsewhere. */ +export function parseHttpsPage(url: string | undefined): URL | null { + if (!url) return null + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' ? parsed : null + } catch { + return null + } +} + +function matchesPath(pathname: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)) +} + +/** + * The host to key the hint by when `page` is a known sign-in page, or null. + * Hosts match on label boundaries, so `accounts.google.com.evil.com` or + * `evil-okta.com` never qualify. + */ +export function signInHintHost({ hostname, pathname }: URL): string | null { + const matched = SIGN_IN_HOSTS.some(({ host, subdomains, paths }) => { + const hostMatches = subdomains ? hostname.endsWith(`.${host}`) : hostname === host + return hostMatches && (!paths || matchesPath(pathname, paths)) + }) + return matched ? hostname : null +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts new file mode 100644 index 00000000000..b8b2531873c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts @@ -0,0 +1,119 @@ +'use client' + +import { useCallback, useSyncExternalStore } from 'react' +import type { BrowserContinueSignInError, BrowserImportProfile } from '@sim/desktop-bridge' +import { toast } from '@sim/emcn' +import { IMPORT_ERROR_MESSAGES } from '@/components/browser-import/browser-import-dialog' +import { + parseHttpsPage, + signInHintHost, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts' +import { + supportsContinueSignIn, + useBringSignInBack, + useBrowserImportProfiles, + useCancelSignInHandoff, + usePendingSignIn, + useStartSignInHandoff, +} from '@/hooks/queries/browser-import' + +const CONTINUE_ERROR_MESSAGES: Record = { + 'chrome-not-found': IMPORT_ERROR_MESSAGES['chrome-not-found'], + 'unsupported-page': 'This page can’t be continued in another browser.', + 'launch-failed': 'Could not open that browser.', + unknown: 'Could not open this page in another browser.', +} + +/** Hosts whose hint the user dismissed, for as long as the app stays open. */ +const dismissedHintHosts = new Set() +const dismissalListeners = new Set<() => void>() + +function subscribeToDismissals(listener: () => void): () => void { + dismissalListeners.add(listener) + return () => { + dismissalListeners.delete(listener) + } +} + +const dismissalCount = () => dismissedHintHosts.size + +interface UseContinueSignInProps { + scopeId: string + pageUrl: string | undefined +} + +/** + * Finishing a sign-in in the user's own browser and bringing the session back + * (see `continueSignIn` on the desktop bridge). A shell without the bridge + * methods, a platform without the importer, or a device with no importable + * browser leaves `canContinue` false, and every surface built on this hides. + */ +export function useContinueSignIn({ scopeId, pageUrl }: UseContinueSignInProps) { + const supported = supportsContinueSignIn() + const { data: profiles = [] } = useBrowserImportProfiles(supported) + const { data: pending = null } = usePendingSignIn(scopeId, supported) + const { mutateAsync: startHandoff, isPending: starting } = useStartSignInHandoff(scopeId) + const { mutateAsync: importBack, isPending: importing } = useBringSignInBack(scopeId) + const { mutate: cancelHandoff } = useCancelSignInHandoff(scopeId) + useSyncExternalStore(subscribeToDismissals, dismissalCount, dismissalCount) + + const page = parseHttpsPage(pageUrl) + const canContinue = profiles.length > 0 && page !== null + const hintHost = page ? signInHintHost(page) : null + const hintVisible = + canContinue && pending === null && hintHost !== null && !dismissedHintHosts.has(hintHost) + + const continueIn = useCallback( + async (profile: BrowserImportProfile) => { + try { + const result = await startHandoff(profile.id) + if (!result.pending) toast.error(CONTINUE_ERROR_MESSAGES[result.error ?? 'unknown']) + } catch { + toast.error(CONTINUE_ERROR_MESSAGES.unknown) + } + }, + [startHandoff] + ) + + const bringBack = useCallback(async () => { + if (!pending) return + const { site, browserLabel } = pending + try { + const result = await importBack() + if (result.error) { + toast.error(IMPORT_ERROR_MESSAGES[result.error]) + } else if (result.cookiesImported === 0) { + toast.warning( + `No sign-in for ${site} found in ${browserLabel}. Make sure you finished signing in.` + ) + } else { + toast.success(`Brought back your ${site} session from ${browserLabel}`) + } + } catch { + toast.error(IMPORT_ERROR_MESSAGES.unknown) + } + }, [importBack, pending]) + + const cancel = useCallback(() => cancelHandoff(), [cancelHandoff]) + + const dismissHint = useCallback(() => { + if (!hintHost) return + dismissedHintHosts.add(hintHost) + for (const listener of dismissalListeners) listener() + }, [hintHost]) + + return { + profiles, + canContinue, + pending, + starting, + importing, + hintVisible, + continueIn, + bringBack, + cancel, + dismissHint, + } +} + +export type ContinueSignInController = ReturnType diff --git a/apps/sim/components/browser-import/browser-import-dialog.tsx b/apps/sim/components/browser-import/browser-import-dialog.tsx index 93032ca0e1c..20c9e863bbd 100644 --- a/apps/sim/components/browser-import/browser-import-dialog.tsx +++ b/apps/sim/components/browser-import/browser-import-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import type { BrowserChromeImportResult, BrowserImportError, @@ -9,8 +9,9 @@ import type { import { toast } from '@sim/emcn' import { ImportModal } from '@/components/browser-import/import-modal' import { getDesktopBridge } from '@/lib/desktop' +import { useBrowserImportProfiles } from '@/hooks/queries/browser-import' -const IMPORT_ERROR_MESSAGES: Record = { +export const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', 'chrome-not-found': 'Could not find that browser profile.', 'keychain-unavailable': @@ -43,38 +44,15 @@ interface BrowserImportDialogProps { /** Shares the same user-initiated import flow between Settings and the browser panel. */ export function BrowserImportDialog({ open, onOpenChange, onImported }: BrowserImportDialogProps) { - const [profiles, setProfiles] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState() + const available = Boolean(getDesktopBridge()?.browserImport?.listChromeProfiles) + const profilesQuery = useBrowserImportProfiles(open && available) const [pending, setPending] = useState(false) const importInFlight = useRef(false) - - useEffect(() => { - if (!open) return - let active = true - setLoading(true) - setError(undefined) - setProfiles([]) - const listProfiles = getDesktopBridge()?.browserImport?.listChromeProfiles - if (!listProfiles) { - setError('Browser import is not available on this device.') - setLoading(false) - return - } - void listProfiles() - .then((next) => { - if (active) setProfiles(next) - }) - .catch(() => { - if (active) setError('Could not load browser profiles. Close this dialog and try again.') - }) - .finally(() => { - if (active) setLoading(false) - }) - return () => { - active = false - } - }, [open]) + const error = !available + ? 'Browser import is not available on this device.' + : profilesQuery.isError + ? 'Could not load browser profiles. Close this dialog and try again.' + : undefined async function importFromBrowser(profile: BrowserImportProfile) { const runImport = getDesktopBridge()?.browserImport?.importFromChrome @@ -118,8 +96,8 @@ export function BrowserImportDialog({ open, onOpenChange, onImported }: BrowserI void importFromBrowser(profile)} diff --git a/apps/sim/hooks/queries/browser-import.ts b/apps/sim/hooks/queries/browser-import.ts new file mode 100644 index 00000000000..a0bf784830c --- /dev/null +++ b/apps/sim/hooks/queries/browser-import.ts @@ -0,0 +1,92 @@ +'use client' + +import type { BrowserImportProfile, BrowserPendingSignIn } from '@sim/desktop-bridge' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getDesktopBridge } from '@/lib/desktop' + +/** Local browser data read through the desktop shell, never the server. */ +const browserImportKeys = { + all: ['browser-import'] as const, + profiles: () => [...browserImportKeys.all, 'profiles'] as const, + pendingSignIns: () => [...browserImportKeys.all, 'pending-sign-in'] as const, + pendingSignIn: (scopeId: string) => [...browserImportKeys.pendingSignIns(), scopeId] as const, +} + +/** Browsers are rarely installed or removed mid-session. */ +const BROWSER_IMPORT_PROFILES_STALE_TIME = 5 * 60 * 1000 +/** The shell owns a pending sign-in, and every change to it goes through the hooks below. */ +const PENDING_SIGN_IN_STALE_TIME = Number.POSITIVE_INFINITY + +function browserImport() { + return getDesktopBridge()?.browserImport +} + +/** Whether this shell can continue a sign-in in another browser and bring it back. */ +export function supportsContinueSignIn(): boolean { + const api = browserImport() + return Boolean( + api?.continueSignIn && api.bringSignInBack && api.cancelSignIn && api.getPendingSignIn + ) +} + +/** Browser profiles the shell can import from; empty when there are none. */ +export function useBrowserImportProfiles(enabled: boolean) { + return useQuery({ + queryKey: browserImportKeys.profiles(), + // rq-lint-allow: a preload IPC call cannot be aborted + queryFn: (): Promise => { + const listProfiles = browserImport()?.listChromeProfiles + if (!listProfiles) throw new Error('Browser import is not available on this device.') + return listProfiles() + }, + enabled, + staleTime: BROWSER_IMPORT_PROFILES_STALE_TIME, + }) +} + +/** The chat's sign-in waiting in another browser, kept by the shell across panel remounts. */ +export function usePendingSignIn(scopeId: string, enabled: boolean) { + return useQuery({ + queryKey: browserImportKeys.pendingSignIn(scopeId), + // rq-lint-allow: a preload IPC call cannot be aborted + queryFn: async (): Promise => + (await browserImport()?.getPendingSignIn?.(scopeId)) ?? null, + enabled, + staleTime: PENDING_SIGN_IN_STALE_TIME, + }) +} + +export function useStartSignInHandoff(scopeId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (profileId: string) => { + const continueSignIn = browserImport()?.continueSignIn + if (!continueSignIn) return { error: 'unknown' as const } + return continueSignIn(profileId, scopeId) + }, + onSuccess: ({ pending }) => { + if (pending) queryClient.setQueryData(browserImportKeys.pendingSignIn(scopeId), pending) + }, + }) +} + +export function useBringSignInBack(scopeId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { + const bringSignInBack = browserImport()?.bringSignInBack + if (!bringSignInBack) throw new Error('Browser import is not available on this device.') + return bringSignInBack(scopeId) + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: browserImportKeys.pendingSignIn(scopeId) }), + }) +} + +export function useCancelSignInHandoff(scopeId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => browserImport()?.cancelSignIn?.(scopeId), + onSuccess: () => queryClient.setQueryData(browserImportKeys.pendingSignIn(scopeId), null), + }) +} diff --git a/bun.lock b/bun.lock index 0525115a6fd..a561eb52ca5 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "micromatch": "4.0.8", "pdf-lib": "1.17.1", "safe-regex2": "5.1.2", + "tldts": "7.4.18", }, "devDependencies": { "@electron/fuses": "1.8.0", diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 48b3cf01e3c..6751beba662 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -501,6 +501,47 @@ interface SimDesktopBrowserImportApi { profileId?: string, policy?: BrowserCredentialConflictPolicy ): Promise + /** + * Continue a sign-in in the user's own browser. + * + * Electron cannot complete some sign-ins that sites now prefer: iCloud + * Keychain and Google Password Manager passkeys, the phone (hybrid/QR) + * passkey flow, and PIN-protected security keys are Chrome-only, so the site + * offers a passkey and then hangs. The user finishes there instead and brings + * the session back. `continueSignIn` opens the active tab's page in the named + * profile (the shell reads the address from the tab, never from the caller); + * `bringSignInBack` imports only that page's registrable-domain cookies from + * the same profile and reopens it. Both need a live user gesture; no browser + * tool maps to any of these. Absent on older shells, which hide the feature. + */ + continueSignIn?(profileId: string, scopeId: string): Promise + /** Imports the chat's pending sign-in back. @see continueSignIn */ + bringSignInBack?(scopeId: string): Promise + /** Drops the chat's pending sign-in. @see continueSignIn */ + cancelSignIn?(scopeId: string): Promise + /** The chat's pending sign-in, so a remounted panel can keep offering to bring it back. */ + getPendingSignIn?(scopeId: string): Promise +} + +/** Why a page could not be continued in another browser. */ +export type BrowserContinueSignInError = + | 'chrome-not-found' + | 'unsupported-page' + | 'launch-failed' + | 'unknown' + +/** A sign-in waiting in another browser to be brought back. */ +export interface BrowserPendingSignIn { + /** The registrable domain a bring-back imports, e.g. `amazon.com`. */ + site: string + /** The browser it was continued in, e.g. `Chrome`. */ + browserLabel: string +} + +/** A page handed to another browser, or why it could not be. */ +export interface BrowserContinueSignInResult { + pending?: BrowserPendingSignIn + error?: BrowserContinueSignInError } /** Both halves of a combined Chrome import, each with its own outcome. */ From ea87b72fc5cf661bc4b360639ec2d884131ce714 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:57:32 -0700 Subject: [PATCH 2/3] fix(desktop): keep sign-in handoffs current across cancel, migration, and sign-out Retire in-flight launches on cancel, newer continue, and account teardown; move a provisional chat's handoff with its browser scope; skip reopening the tab when a bring-back's handoff was superseded; pin the platform in the continue-sign-in tests so they pass on Linux CI. Co-Authored-By: Claude Opus 5.5 --- .../src/main/browser-import/index.test.ts | 95 +++++++++++++++++-- apps/desktop/src/main/browser-import/index.ts | 84 ++++++++++++---- apps/desktop/src/main/index.ts | 3 + apps/desktop/src/main/ipc.test.ts | 28 ++++++ apps/desktop/src/main/ipc.ts | 6 +- .../browser-session/use-continue-sign-in.ts | 2 +- packages/desktop-bridge/src/index.ts | 5 +- 7 files changed, 191 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/browser-import/index.test.ts b/apps/desktop/src/main/browser-import/index.test.ts index db943d75958..56cef1e287b 100644 --- a/apps/desktop/src/main/browser-import/index.test.ts +++ b/apps/desktop/src/main/browser-import/index.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +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' @@ -35,9 +35,33 @@ vi.mock('@/main/browser-import/chromium-crypto', async (importOriginal) => ({ 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, continueSignIn, getPendingSignIn } = await import( - '@/main/browser-import' -) +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 = { @@ -71,12 +95,10 @@ function readsCookies(count: number) { } beforeEach(() => { - vi.clearAllMocks() vi.mocked(listAllBrowserProfiles).mockResolvedValue([PROFILE]) vi.mocked(openInBrowserProfile).mockResolvedValue() readsCookies(2) - cancelSignIn('chat-a') - cancelSignIn('chat-b') + clearSignIns() }) describe('continueSignIn', () => { @@ -132,6 +154,7 @@ describe('bringSignInBack', () => { 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 }, @@ -167,7 +190,10 @@ describe('bringSignInBack', () => { 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 } }) + await expect(broughtBack).resolves.toMatchObject({ + result: { cookiesImported: 2 }, + current: false, + }) expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' }) }) @@ -189,3 +215,56 @@ describe('bringSignInBack', () => { 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 918d11236c6..d4140f32fe1 100644 --- a/apps/desktop/src/main/browser-import/index.ts +++ b/apps/desktop/src/main/browser-import/index.ts @@ -89,20 +89,36 @@ interface SignInHandoff { scope: SiteScope } -/** At most one per chat: continuing another page replaces the previous handoff. */ -const handoffsByScope = new Map() +/** + * 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. + * 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, @@ -110,45 +126,73 @@ export async function continueSignIn( ): 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 { error: 'chrome-not-found' } + if (!profile?.cookiesPath) return retire(slot, { error: 'chrome-not-found' }) try { await openInBrowserProfile(profile.source.bundleId, profile.directory, request.url) } catch { - return { error: 'launch-failed' } + 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) } - const handoff = { profile, tabId: request.tabId, url: request.url, scope } - handoffsByScope.set(scopeId, handoff) - return { pending: pendingSignIn(handoff) } + return result } export function getPendingSignIn(scopeId: string): BrowserPendingSignIn | null { - const handoff = handoffsByScope.get(scopeId) + const handoff = slotsByScope.get(scopeId)?.handoff return handoff ? pendingSignIn(handoff) : null } export function cancelSignIn(scopeId: string): void { - handoffsByScope.delete(scopeId) + 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. + * 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 } }> { - const handoff = handoffsByScope.get(scopeId) - if (!handoff) return { result: cookieFailure('unknown') } +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 ) - if (result.cookiesImported > 0 && handoffsByScope.get(scopeId) === handoff) { - handoffsByScope.delete(scopeId) - } - return { result, handoff } + 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/index.ts b/apps/desktop/src/main/index.ts index 161d4ae8526..331ab4df94b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -54,6 +54,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' @@ -395,6 +396,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', @@ -939,6 +941,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', diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index fe658ee67f8..b5580f76393 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -26,9 +26,11 @@ vi.mock('@/main/browser-import', () => ({ 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', () => ({ @@ -149,6 +151,7 @@ import { importChromeCookies, importChromePasswords, listChromeImportProfiles, + migrateSignIn, } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { ComputerUseService } from '@/main/computer-use/service' @@ -1149,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') @@ -1305,10 +1320,23 @@ describe('registerIpcHandlers', () => { 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') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index d0e3678bb9c..71a8d3a3c1a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -88,6 +88,7 @@ import { importChromeData, importChromePasswords, listChromeImportProfiles, + migrateSignIn, } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { listSites } from '@/main/browser-sites' @@ -1182,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) @@ -1549,8 +1551,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: async (sender, rawScope) => { const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) if (!scopeId) return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' } - const { result, handoff } = await bringSignInBack(scopeId) - if (!handoff || result.cookiesImported === 0) return result + 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) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts index b8b2531873c..a1ffb476a9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts @@ -67,7 +67,7 @@ export function useContinueSignIn({ scopeId, pageUrl }: UseContinueSignInProps) async (profile: BrowserImportProfile) => { try { const result = await startHandoff(profile.id) - if (!result.pending) toast.error(CONTINUE_ERROR_MESSAGES[result.error ?? 'unknown']) + if (result.error) toast.error(CONTINUE_ERROR_MESSAGES[result.error]) } catch { toast.error(CONTINUE_ERROR_MESSAGES.unknown) } diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 6751beba662..43c36eb48e0 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -538,7 +538,10 @@ export interface BrowserPendingSignIn { browserLabel: string } -/** A page handed to another browser, or why it could not be. */ +/** + * A page handed to another browser, or why it could not be. Neither is set when + * a cancel or a newer continue retired the handoff while the browser launched. + */ export interface BrowserContinueSignInResult { pending?: BrowserPendingSignIn error?: BrowserContinueSignInError From 35c64f87ad495d1463e32b7956197e1b3e2dc458 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 02:24:42 -0700 Subject: [PATCH 3/3] fix(desktop): refresh pending sign-in after every continue and gate on shell support Re-read the chat's pending sign-in after any continue result so a failed launch that retired the previous handoff cannot leave a stale notice, and require the shell's continue-sign-in bridge before showing the feature, since the profiles cache is shared with the import dialog on older shells. Co-Authored-By: Claude Opus 5.5 --- .../components/browser-session/use-continue-sign-in.ts | 7 +++++-- apps/sim/hooks/queries/browser-import.ts | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts index a1ffb476a9e..ae232cf2a41 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts @@ -51,14 +51,17 @@ interface UseContinueSignInProps { export function useContinueSignIn({ scopeId, pageUrl }: UseContinueSignInProps) { const supported = supportsContinueSignIn() const { data: profiles = [] } = useBrowserImportProfiles(supported) - const { data: pending = null } = usePendingSignIn(scopeId, supported) + const { data: pendingData = null } = usePendingSignIn(scopeId, supported) + const pending = supported ? pendingData : null const { mutateAsync: startHandoff, isPending: starting } = useStartSignInHandoff(scopeId) const { mutateAsync: importBack, isPending: importing } = useBringSignInBack(scopeId) const { mutate: cancelHandoff } = useCancelSignInHandoff(scopeId) useSyncExternalStore(subscribeToDismissals, dismissalCount, dismissalCount) const page = parseHttpsPage(pageUrl) - const canContinue = profiles.length > 0 && page !== null + // The profiles cache is shared with the import dialog, which older shells + // also support, so cached profiles alone must not reveal this feature. + const canContinue = supported && profiles.length > 0 && page !== null const hintHost = page ? signInHintHost(page) : null const hintVisible = canContinue && pending === null && hintHost !== null && !dismissedHintHosts.has(hintHost) diff --git a/apps/sim/hooks/queries/browser-import.ts b/apps/sim/hooks/queries/browser-import.ts index a0bf784830c..dd787ae07a6 100644 --- a/apps/sim/hooks/queries/browser-import.ts +++ b/apps/sim/hooks/queries/browser-import.ts @@ -67,6 +67,10 @@ export function useStartSignInHandoff(scopeId: string) { onSuccess: ({ pending }) => { if (pending) queryClient.setQueryData(browserImportKeys.pendingSignIn(scopeId), pending) }, + // A failed or superseded launch can still retire the chat's previous + // handoff, so the shell's answer replaces whatever the cache last held. + onSettled: () => + queryClient.invalidateQueries({ queryKey: browserImportKeys.pendingSignIn(scopeId) }), }) }