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
+Open Continue Back
+`)
+ })
+ 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..56cef1e287b
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/index.test.ts
@@ -0,0 +1,270 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources'
+import type { BrowserProfile } from '@/main/browser-import/types'
+import { emptySkipCounts } from '@/main/browser-import/types'
+
+/**
+ * Continuing a sign-in elsewhere and bringing it back, against stand-ins for
+ * the only things a test must never touch: the developer's real browser
+ * profiles, their Keychain, the browser launcher, and the Electron cookie jar.
+ */
+vi.mock('electron', () => import('@/test/electron-mock'))
+vi.mock('@/main/browser-agent/session', () => ({
+ importAgentCookies: vi.fn(async (cookies: unknown[]) => ({
+ imported: cookies.length,
+ failed: 0,
+ })),
+}))
+vi.mock('@/main/browser-credentials', () => ({
+ credentialsAvailable: () => false,
+ importCredentials: vi.fn(),
+}))
+vi.mock('@/main/browser-sites', () => ({ rememberSites: vi.fn(async () => {}) }))
+vi.mock('@/main/account-data-generation', () => ({
+ captureAccountDataGeneration: () => 0,
+ runAccountDataMutation: (_generation: number, operation: () => Promise) => operation(),
+}))
+vi.mock('@/main/browser-import/browser-launch', () => ({ openInBrowserProfile: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-profiles', () => ({ listAllBrowserProfiles: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-cookies', () => ({ readBrowserCookies: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-crypto', async (importOriginal) => ({
+ ...(await importOriginal()),
+ readSafeStoragePassword: vi.fn(async () => 'safe-storage-password'),
+}))
+
+const { openInBrowserProfile } = await import('@/main/browser-import/browser-launch')
+const { listAllBrowserProfiles } = await import('@/main/browser-import/chromium-profiles')
+const { readBrowserCookies } = await import('@/main/browser-import/chromium-cookies')
+const {
+ bringSignInBack,
+ cancelSignIn,
+ clearSignIns,
+ continueSignIn,
+ getPendingSignIn,
+ migrateSignIn,
+} = await import('@/main/browser-import')
+
+/** The importer is macOS-only; pin the platform so the suite means the same on every runner. */
+const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin' }))
+afterAll(() => {
+ if (realPlatform) Object.defineProperty(process, 'platform', realPlatform)
+})
+
+/** Holds the next browser launch open until the test releases it. */
+function holdLaunch(): () => void {
+ let release: (() => void) | undefined
+ vi.mocked(openInBrowserProfile).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ release = resolve
+ })
+ )
+ return () => release?.()
+}
+
+const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource
+const PROFILE: BrowserProfile = {
+ id: 'arc:Profile 2',
+ directory: 'Profile 2',
+ label: 'Work',
+ source: ARC,
+ cookiesPath: '/arc/Profile 2/Cookies',
+ loginDataPaths: [],
+ faviconsPath: null,
+ historyPath: null,
+}
+const SIGN_IN_URL =
+ 'https://signin.aws.amazon.com/oauth?redirect_uri=https%3A%2F%2Fconsole.aws.amazon.com'
+const request = (url = SIGN_IN_URL, profileId = PROFILE.id) => ({ profileId, tabId: 'tab-1', url })
+
+function readsCookies(count: number) {
+ vi.mocked(readBrowserCookies).mockResolvedValue({
+ cookies: Array.from({ length: count }, (_, index) => ({
+ url: 'https://console.aws.amazon.com/',
+ name: `session-${index}`,
+ value: 'value',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ })),
+ skipped: emptySkipCounts(),
+ rowsSeen: count,
+ })
+}
+
+beforeEach(() => {
+ vi.mocked(listAllBrowserProfiles).mockResolvedValue([PROFILE])
+ vi.mocked(openInBrowserProfile).mockResolvedValue()
+ readsCookies(2)
+ clearSignIns()
+})
+
+describe('continueSignIn', () => {
+ it('opens the exact page in the chosen profile and remembers it for the chat', async () => {
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({
+ pending: { site: 'amazon.com', browserLabel: 'Arc' },
+ })
+ expect(openInBrowserProfile).toHaveBeenCalledWith(
+ 'company.thebrowser.Browser',
+ 'Profile 2',
+ SIGN_IN_URL
+ )
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' })
+ expect(getPendingSignIn('chat-b')).toBeNull()
+ })
+
+ it.each([
+ ['plain http', 'http://example.com/login'],
+ ['a local file', 'file:///etc/passwd'],
+ ['script', 'javascript:alert(1)'],
+ ['embedded credentials', 'https://user:secret@example.com/'],
+ ['garbage', 'not a url'],
+ ])('refuses %s without launching anything', async (_label, url) => {
+ await expect(continueSignIn('chat-a', request(url))).resolves.toEqual({
+ error: 'unsupported-page',
+ })
+ expect(openInBrowserProfile).not.toHaveBeenCalled()
+ })
+
+ it('refuses a profile it cannot bring a sign-in back from', async () => {
+ await expect(
+ continueSignIn('chat-a', request(SIGN_IN_URL, 'chrome:Profile 9'))
+ ).resolves.toEqual({ error: 'chrome-not-found' })
+ vi.mocked(listAllBrowserProfiles).mockResolvedValue([{ ...PROFILE, cookiesPath: null }])
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({
+ error: 'chrome-not-found',
+ })
+ expect(openInBrowserProfile).not.toHaveBeenCalled()
+ })
+
+ it('remembers nothing when the browser will not launch', async () => {
+ vi.mocked(openInBrowserProfile).mockRejectedValue(new Error('Unable to find application'))
+
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({ error: 'launch-failed' })
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+})
+
+describe('bringSignInBack', () => {
+ it('imports only the handed-off site from that profile, without rescanning profiles', async () => {
+ await continueSignIn('chat-a', request())
+
+ await expect(bringSignInBack('chat-a')).resolves.toEqual({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: expect.objectContaining({ tabId: 'tab-1', url: SIGN_IN_URL }),
+ current: true,
+ })
+ expect(readBrowserCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer), {
+ scope: { domain: 'amazon.com', exact: false },
+ })
+ expect(listAllBrowserProfiles).toHaveBeenCalledOnce()
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+
+ it('keeps the handoff pending when nothing came back, so the user can retry', async () => {
+ await continueSignIn('chat-a', request())
+ readsCookies(0)
+
+ await expect(bringSignInBack('chat-a')).resolves.toMatchObject({
+ result: { cookiesImported: 0, cookiesSkipped: 0 },
+ })
+ expect(getPendingSignIn('chat-a')).not.toBeNull()
+ })
+
+ it('keeps a newer handoff that replaced the one being brought back', async () => {
+ await continueSignIn('chat-a', request())
+ const signedIn = await vi.mocked(readBrowserCookies).getMockImplementation()?.(
+ '',
+ Buffer.alloc(0)
+ )
+ let finishRead: (() => void) | undefined
+ vi.mocked(readBrowserCookies).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = () => signedIn && resolve(signedIn)
+ })
+ )
+ const broughtBack = bringSignInBack('chat-a')
+ await vi.waitFor(() => expect(finishRead).toBeTypeOf('function'))
+ await continueSignIn('chat-a', request('https://accounts.google.com/signin'))
+ finishRead?.()
+ await expect(broughtBack).resolves.toMatchObject({
+ result: { cookiesImported: 2 },
+ current: false,
+ })
+
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' })
+ })
+
+ it('answers only to the chat that started the handoff', async () => {
+ await continueSignIn('chat-a', request())
+
+ await expect(bringSignInBack('chat-b')).resolves.toEqual({
+ result: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
+ })
+ expect(readBrowserCookies).not.toHaveBeenCalled()
+ })
+
+ it('has nothing to bring back once the user cancels', async () => {
+ await continueSignIn('chat-a', request())
+ cancelSignIn('chat-a')
+
+ await expect(bringSignInBack('chat-a')).resolves.toMatchObject({ result: { error: 'unknown' } })
+ expect(readBrowserCookies).not.toHaveBeenCalled()
+ })
+})
+
+describe('a handoff retired or moved while its browser launches', () => {
+ it('records nothing once a cancel lands mid-launch', async () => {
+ const release = holdLaunch()
+ const continuing = continueSignIn('chat-a', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ cancelSignIn('chat-a')
+ release()
+
+ await expect(continuing).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+
+ it('lets a newer continue win over an older launch that finishes later', async () => {
+ const release = holdLaunch()
+ const older = continueSignIn('chat-a', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ await continueSignIn('chat-a', request('https://accounts.google.com/signin'))
+ release()
+
+ await expect(older).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' })
+ })
+
+ it('follows a provisional chat to its saved id, mid-launch or after', async () => {
+ const release = holdLaunch()
+ const continuing = continueSignIn('pending:new-chat', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ migrateSignIn('pending:new-chat', 'chat-a')
+ release()
+ await continuing
+
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' })
+ expect(getPendingSignIn('pending:new-chat')).toBeNull()
+ migrateSignIn('chat-a', 'chat-b')
+ await expect(bringSignInBack('chat-b')).resolves.toMatchObject({
+ result: { cookiesImported: 2 },
+ })
+ })
+
+ it('forgets every handoff on account teardown, including one still launching', async () => {
+ await continueSignIn('chat-a', request())
+ const release = holdLaunch()
+ const continuing = continueSignIn('chat-b', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalledTimes(2))
+ clearSignIns()
+ release()
+
+ await expect(continuing).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ expect(getPendingSignIn('chat-b')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts
index 299de7bcceb..d4140f32fe1 100644
--- a/apps/desktop/src/main/browser-import/index.ts
+++ b/apps/desktop/src/main/browser-import/index.ts
@@ -1,9 +1,11 @@
import type {
BrowserChromeImportResult,
+ BrowserContinueSignInResult,
BrowserCredentialConflictPolicy,
BrowserImportProfile,
BrowserImportResult,
BrowserPasswordImportResult,
+ BrowserPendingSignIn,
} from '@sim/desktop-bridge'
import {
captureAccountDataGeneration,
@@ -11,6 +13,7 @@ import {
} from '@/main/account-data-generation'
import { importAgentCookies } from '@/main/browser-agent/session'
import { credentialsAvailable, importCredentials } from '@/main/browser-credentials'
+import { openInBrowserProfile } from '@/main/browser-import/browser-launch'
import { readBrowserCookies } from '@/main/browser-import/chromium-cookies'
import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto'
import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons'
@@ -18,13 +21,17 @@ import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords'
import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles'
import { readBrowserSites } from '@/main/browser-import/chromium-site-names'
import {
+ cookieFailure,
type ImportServiceDeps,
listImportableProfiles,
importChromeCookies as runCookieImport,
importChromeData as runDataImport,
importChromePasswords as runPasswordImport,
} from '@/main/browser-import/import-service'
+import { type SiteScope, siteScopeFor } from '@/main/browser-import/site-scope'
+import type { BrowserProfile } from '@/main/browser-import/types'
import { rememberSites } from '@/main/browser-sites'
+import { isSafeExternalUrl } from '@/main/navigation'
/**
* Composition root for the Chrome importer: binds the real Keychain, profile,
@@ -37,7 +44,7 @@ function deps(): ImportServiceDeps {
platform: process.platform,
listProfiles: () => listAllBrowserProfiles(),
readSafeStoragePassword: (item) => readSafeStoragePassword(item),
- readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key),
+ readCookies: (cookiesPath, key, scope) => readBrowserCookies(cookiesPath, key, { scope }),
writeCookies: (cookies) => importAgentCookies(cookies),
readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key),
readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins),
@@ -72,3 +79,120 @@ export function importChromeData(
): Promise {
return runDataImport(profileId, policy, deps())
}
+
+/** A page the user took to another browser to finish signing in. */
+interface SignInHandoff {
+ /** Resolved once, so bringing the sign-in back reads exactly this profile. */
+ profile: BrowserProfile
+ tabId: string
+ url: string
+ scope: SiteScope
+}
+
+/**
+ * A chat's handoff, created before the browser launches so a cancel, a newer
+ * continue, sign-out, or the chat's id migrating during the launch can retire
+ * or move it. A launch records its handoff only while its slot is still current.
+ */
+interface HandoffSlot {
+ handoff?: SignInHandoff
+}
+
+/** At most one per chat: continuing another page replaces the previous slot. */
+const slotsByScope = new Map()
+
+function pendingSignIn({ profile, scope }: SignInHandoff): BrowserPendingSignIn {
+ return { site: scope.domain, browserLabel: profile.source.label }
+}
+
+function isCurrentSlot(slot: HandoffSlot): boolean {
+ for (const current of slotsByScope.values()) if (current === slot) return true
+ return false
+}
+
+/**
+ * Opens a built-in browser page in one of the user's own browser profiles and
+ * remembers it for the chat, so {@link bringSignInBack} imports the site the
+ * user left from and reopens that page, whatever the tab shows by then.
+ *
+ * Only an https page goes, and only to a profile the importer can read back
+ * from: a sign-in that can never return would strand the user. A handoff
+ * retired while its browser launched resolves with neither `pending` nor
+ * `error`.
+ */
+export async function continueSignIn(
+ scopeId: string,
+ request: { profileId: string; tabId: string; url: string }
+): Promise {
+ const scope = isSafeExternalUrl(request.url) ? siteScopeFor(new URL(request.url).hostname) : null
+ if (!scope) return { error: 'unsupported-page' }
+ const slot: HandoffSlot = {}
+ slotsByScope.set(scopeId, slot)
+ const profiles = await listAllBrowserProfiles().catch(() => [])
+ const profile = profiles.find(({ id }) => id === request.profileId)
+ if (!profile?.cookiesPath) return retire(slot, { error: 'chrome-not-found' })
+ try {
+ await openInBrowserProfile(profile.source.bundleId, profile.directory, request.url)
+ } catch {
+ return retire(slot, { error: 'launch-failed' })
+ }
+ if (!isCurrentSlot(slot)) return {}
+ slot.handoff = { profile, tabId: request.tabId, url: request.url, scope }
+ return { pending: pendingSignIn(slot.handoff) }
+}
+
+function retire(
+ slot: HandoffSlot,
+ result: BrowserContinueSignInResult
+): BrowserContinueSignInResult {
+ for (const [scopeId, current] of slotsByScope) {
+ if (current === slot) slotsByScope.delete(scopeId)
+ }
+ return result
+}
+
+export function getPendingSignIn(scopeId: string): BrowserPendingSignIn | null {
+ const handoff = slotsByScope.get(scopeId)?.handoff
+ return handoff ? pendingSignIn(handoff) : null
+}
+
+export function cancelSignIn(scopeId: string): void {
+ slotsByScope.delete(scopeId)
+}
+
+/** Follows a provisional chat to its saved id, including a launch still in flight. */
+export function migrateSignIn(fromScopeId: string, toScopeId: string): void {
+ const slot = slotsByScope.get(fromScopeId)
+ if (!slot) return
+ slotsByScope.delete(fromScopeId)
+ slotsByScope.set(toScopeId, slot)
+}
+
+/** Account teardown: forgets every handoff and retires launches still in flight. */
+export function clearSignIns(): void {
+ slotsByScope.clear()
+}
+
+/**
+ * Imports the chat's handed-off site from the profile it was continued in. The
+ * handoff stays pending until something comes back, so the user can finish
+ * signing in and try again. `current` is false when a cancel, a newer
+ * continue, or teardown retired the handoff while it was being imported.
+ */
+export async function bringSignInBack(scopeId: string): Promise<{
+ result: BrowserImportResult
+ handoff?: { tabId: string; url: string }
+ current?: boolean
+}> {
+ const slot = slotsByScope.get(scopeId)
+ const handoff = slot?.handoff
+ if (!slot || !handoff) return { result: cookieFailure('unknown') }
+ const result = await runCookieImport(
+ handoff.profile.id,
+ { ...deps(), listProfiles: async () => [handoff.profile] },
+ handoff.scope
+ )
+ const current = slotsByScope.get(scopeId) === slot && slot.handoff === handoff
+ if (current && result.cookiesImported > 0) slotsByScope.delete(scopeId)
+ return { result, handoff, current }
+}
diff --git a/apps/desktop/src/main/browser-import/site-scope.test.ts b/apps/desktop/src/main/browser-import/site-scope.test.ts
new file mode 100644
index 00000000000..5475fcb3824
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/site-scope.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import { isCookieHostInScope, siteScopeFor } from '@/main/browser-import/site-scope'
+
+/**
+ * A per-site import must bring back the sign-in it was asked for and nothing
+ * else. Each case is a way the scope could leak another site's cookies into
+ * the built-in browser, or drop the ones the user just signed in for.
+ */
+describe('siteScopeFor', () => {
+ it.each([
+ [
+ 'a sign-in host widens to its registrable domain',
+ 'signin.aws.amazon.com',
+ 'amazon.com',
+ false,
+ ],
+ ['a private suffix tenant stays itself', 'alice.github.io', 'alice.github.io', false],
+ [
+ 'an AWS access portal stays itself',
+ 'd-1234567890.awsapps.com',
+ 'd-1234567890.awsapps.com',
+ false,
+ ],
+ ['case and a root dot normalize', 'WWW.Example.COM.', 'example.com', false],
+ ['an IPv4 host is exact', '127.0.0.1', '127.0.0.1', true],
+ ['an IPv6 host is exact', '[::1]', '[::1]', true],
+ ['a single-label host is exact', 'localhost', 'localhost', true],
+ ])('%s', (_label, host, domain, exact) => {
+ expect(siteScopeFor(host)).toEqual({ domain, exact })
+ })
+
+ it.each(['', '.'])('refuses the empty host %j', (host) => {
+ expect(siteScopeFor(host)).toBeNull()
+ })
+})
+
+describe('isCookieHostInScope', () => {
+ function inScope(pageHost: string, hostKey: string): boolean {
+ const scope = siteScopeFor(pageHost)
+ if (!scope) throw new Error(`No scope for ${pageHost}`)
+ return isCookieHostInScope(hostKey, scope)
+ }
+
+ it.each([
+ ['www.example.com', 'example.com'],
+ ['www.example.com', 'sub.example.com'],
+ ['www.example.com', '.example.com'],
+ ['www.example.com', '.deep.sub.example.com'],
+ ['example.com', 'Sub.EXAMPLE.com'],
+ ['signin.aws.amazon.com', 'console.aws.amazon.com'],
+ ['alice.github.io', '.alice.github.io'],
+ ['127.0.0.1', '127.0.0.1'],
+ ['localhost', 'localhost'],
+ ])('a page on %s brings back %s', (pageHost, hostKey) => {
+ expect(inScope(pageHost, hostKey)).toBe(true)
+ })
+
+ it.each([
+ ['example.com', 'example.com.evil.com'],
+ ['example.com', '.example.com.evil.com'],
+ ['example.com', 'notexample.com'],
+ ['example.com', '.notexample.com'],
+ ['example.com', 'evil.com'],
+ ['example.com', '.com'],
+ ['example.com', ''],
+ ['example.com', '.'],
+ ['signin.aws.amazon.com', 'amazon.co.uk'],
+ ['alice.github.io', 'bob.github.io'],
+ ['alice.github.io', '.github.io'],
+ ['127.0.0.1', '127.0.0.10'],
+ ['127.0.0.1', '10.127.0.0.1'],
+ ['localhost', 'evil.localhost'],
+ ])('a page on %s never brings back %s', (pageHost, hostKey) => {
+ expect(inScope(pageHost, hostKey)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/site-scope.ts b/apps/desktop/src/main/browser-import/site-scope.ts
new file mode 100644
index 00000000000..d51bbddd8ef
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/site-scope.ts
@@ -0,0 +1,45 @@
+import { parse } from 'tldts'
+
+/**
+ * The cookies a per-site import may bring back from another browser.
+ *
+ * A sign-in rarely stays on the host the user started from — AWS signs in on
+ * `signin.aws.amazon.com` and lands on `console.aws.amazon.com` — so the scope
+ * is the page's registrable domain (eTLD+1), which covers both. Private
+ * registry suffixes count as public here (`github.io`, `awsapps.com`), so one
+ * tenant of a shared host never brings back its neighbours' cookies.
+ */
+export interface SiteScope {
+ /** The registrable domain, or the exact host when it has none. */
+ domain: string
+ /** True when only cookies for `domain` itself qualify, never its subdomains. */
+ exact: boolean
+}
+
+/**
+ * Whether `host` is `domain` or one of its subdomains, matched on label
+ * boundaries so `notexample.com` and `example.com.evil.com` never qualify.
+ */
+export function isSameOrSubdomain(host: string, domain: string): boolean {
+ return host === domain || host.endsWith(`.${domain}`)
+}
+
+/** The scope for a page on `hostname`, or null when there is no host to scope to. */
+export function siteScopeFor(hostname: string): SiteScope | null {
+ const host = hostname.trim().toLowerCase().replace(/\.$/, '')
+ if (host.length === 0) return null
+ const parsed = parse(host, { allowPrivateDomains: true })
+ if (parsed.domain) return { domain: parsed.domain, exact: false }
+ // An IP, a single-label host, or a bare public suffix has no registrable
+ // parent to widen to, and its subdomains are other sites.
+ return { domain: host, exact: true }
+}
+
+/**
+ * Whether a cookie stored under Chromium's `host_key` belongs to `scope`. A
+ * domain cookie on the suffix itself (`.github.io`) is no one tenant's sign-in.
+ */
+export function isCookieHostInScope(hostKey: string, scope: SiteScope): boolean {
+ const host = hostKey.toLowerCase().replace(/^\./, '')
+ return scope.exact ? host === scope.domain : isSameOrSubdomain(host, scope.domain)
+}
diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts
index 8dcbb70033c..2cbb2eb6689 100644
--- a/apps/desktop/src/main/browser-import/sqlite-source.ts
+++ b/apps/desktop/src/main/browser-import/sqlite-source.ts
@@ -1,6 +1,7 @@
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import type { SQLInputValue } from 'node:sqlite'
import { ImportFailure } from '@/main/browser-import/types'
/**
@@ -17,7 +18,11 @@ import { ImportFailure } from '@/main/browser-import/types'
/** SQLite keeps recent writes beside the main file; sidecars are needed for a faithful copy. */
const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal']
-async function queryCopy(databasePath: string, query: string): Promise[]> {
+async function queryCopy(
+ databasePath: string,
+ query: string,
+ params: readonly SQLInputValue[]
+): Promise[]> {
// Imported lazily: `node:sqlite` is only needed on the import path, and this
// keeps module load working on runtimes that lack it.
const { DatabaseSync } = await import('node:sqlite')
@@ -40,7 +45,7 @@ async function queryCopy(databasePath: string, query: string): Promise[]
+ return statement.all(...params) as Record[]
} catch {
throw new ImportFailure('unsupported-schema', 'The Chrome table is not in a recognised shape.')
} finally {
@@ -50,12 +55,14 @@ async function queryCopy(databasePath: string, query: string): Promise[]> {
const staging = await mkdtemp(join(tmpdir(), 'sim-chrome-import-'))
try {
@@ -69,7 +76,7 @@ export async function queryBrowserDatabase(
// Absent sidecars are normal: they only exist while a transaction is live.
await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {})
}
- return await queryCopy(workingCopy, query)
+ return await queryCopy(workingCopy, query, params)
} finally {
await rm(staging, { recursive: true, force: true }).catch(() => {})
}
diff --git a/apps/desktop/src/main/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 aeece41f983..b5580f76393 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -20,6 +20,17 @@ vi.mock('@/main/browser-import', () => ({
passwordsUpdated: 0,
passwordsSkipped: 1,
})),
+ continueSignIn: vi.fn(async () => ({
+ pending: { site: 'example.com', browserLabel: 'Chrome' },
+ })),
+ bringSignInBack: vi.fn(async () => ({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: true,
+ })),
+ cancelSignIn: vi.fn(),
+ getPendingSignIn: vi.fn(() => null),
+ migrateSignIn: vi.fn(),
}))
vi.mock('@/main/browser-search/suggestions', () => ({
@@ -135,9 +146,12 @@ import {
revealCredential,
} from '@/main/browser-credentials'
import {
+ bringSignInBack,
+ continueSignIn,
importChromeCookies,
importChromePasswords,
listChromeImportProfiles,
+ migrateSignIn,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { ComputerUseService } from '@/main/computer-use/service'
@@ -1138,11 +1152,23 @@ describe('registerIpcHandlers', () => {
})
expect(migrate).not.toHaveBeenCalled()
+ expect(migrateSignIn).not.toHaveBeenCalled()
expect(deps.scopeEvents.activateBrowser).toHaveBeenCalledTimes(1)
migrate.mockRestore()
})
+ it('moves a pending sign-in with the browser scope when a new chat gets its id', async () => {
+ const migrate = vi.spyOn(browserDriver, 'migrateBrowserScope').mockReturnValue(true)
+ const { invoke } = collectHandlers()
+
+ await invoke.get('browser-agent:activate-scope')?.(appEvent, 'pending:signin')
+ await invoke.get('browser-agent:migrate-scope')?.(appEvent, 'pending:signin', 'chat-saved')
+
+ expect(migrateSignIn).toHaveBeenCalledWith('pending:signin', 'chat-saved')
+ migrate.mockRestore()
+ })
+
it('requires a live user gesture before importing Chrome cookies', async () => {
const { invoke } = collectHandlers()
const handler = invoke.get('browser-import:cookies')
@@ -1164,6 +1190,161 @@ describe('registerIpcHandlers', () => {
expect(importChromeCookies).toHaveBeenCalledWith('Default')
})
+ describe('continuing a sign-in in another browser', () => {
+ const SIGN_IN_URL = 'https://signin.example.com/login'
+ let tabUrl = SIGN_IN_URL
+ let active: ReturnType
+
+ beforeEach(async () => {
+ tabUrl = SIGN_IN_URL
+ active = vi.spyOn(browserSession, 'activeTab').mockReturnValue({
+ id: 'tab-1',
+ view: { webContents: { getURL: () => tabUrl } },
+ } as never)
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ activeAppEvent,
+ 'chat-signin'
+ )
+ })
+
+ afterEach(() => active.mockRestore())
+
+ const continueIn = (event: typeof activeAppEvent, ...args: unknown[]) =>
+ collectHandlers().invoke.get('browser-import:continue-sign-in')?.(event, ...args)
+
+ it('hands over the page the tab shows, ignoring any address the renderer names', async () => {
+ await expect(
+ continueIn(activeAppEvent, 'chrome:Default', 'chat-signin', 'https://evil.example/')
+ ).resolves.toEqual({ pending: { site: 'example.com', browserLabel: 'Chrome' } })
+ expect(continueSignIn).toHaveBeenCalledWith('chat-signin', {
+ profileId: 'chrome:Default',
+ tabId: 'tab-1',
+ url: SIGN_IN_URL,
+ })
+ })
+
+ it('refuses a renderer acting without the user', async () => {
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ inactiveAppEvent,
+ 'chat-scripted'
+ )
+ expect(await continueIn(inactiveAppEvent, 'chrome:Default', 'chat-scripted')).toEqual({
+ error: 'unknown',
+ })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+
+ it('refuses a foreign origin and a malformed profile', async () => {
+ expect(await continueIn(evilEvent, 'chrome:Default', 'chat-signin')).toEqual({
+ error: 'unknown',
+ })
+ expect(await continueIn(activeAppEvent, 42, 'chat-signin')).toEqual({ error: 'unknown' })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+
+ it("refuses Sim's own pages, which ride the app session", async () => {
+ tabUrl = `${APP}/workspace/ws1/files`
+ expect(await continueIn(activeAppEvent, 'chrome:Default', 'chat-signin')).toEqual({
+ error: 'unsupported-page',
+ })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('bringing a sign-in back', () => {
+ let panelAction: ReturnType
+ let peek: ReturnType
+ const tabsWith = (...tabIds: string[]) => ({
+ scopeId: 'chat-signin',
+ tabs: tabIds.map((tabId) => ({ tabId, url: '', title: '', loading: false, active: false })),
+ activeTabId: 'tab-2',
+ })
+
+ beforeEach(async () => {
+ panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue()
+ peek = vi.spyOn(browserSession, 'peekTabsState').mockReturnValue(tabsWith('tab-1', 'tab-2'))
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ activeAppEvent,
+ 'chat-signin'
+ )
+ })
+
+ afterEach(() => {
+ panelAction.mockRestore()
+ peek.mockRestore()
+ })
+
+ const bringBack = (event: typeof activeAppEvent, scope: string) =>
+ collectHandlers().invoke.get('browser-import:bring-sign-in-back')?.(event, scope)
+
+ it('refuses a renderer acting without the user, and a foreign origin', async () => {
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ inactiveAppEvent,
+ 'chat-scripted'
+ )
+ expect(await bringBack(inactiveAppEvent, 'chat-scripted')).toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ error: 'unknown',
+ })
+ expect(await bringBack(evilEvent, 'chat-signin')).toMatchObject({ error: 'unknown' })
+ expect(bringSignInBack).not.toHaveBeenCalled()
+ })
+
+ it('reopens the handed-off page in its own tab', async () => {
+ await expect(bringBack(activeAppEvent, 'chat-signin')).resolves.toEqual({
+ cookiesImported: 2,
+ cookiesSkipped: 0,
+ })
+ expect(bringSignInBack).toHaveBeenCalledWith('chat-signin')
+ expect(panelAction.mock.calls).toEqual([
+ ['chat-signin', { action: 'switch-tab', tabId: 'tab-1' }],
+ ['chat-signin', { action: 'navigate', url: 'https://signin.example.com/login' }],
+ ])
+ })
+
+ it('reopens the page in a new tab when its tab was closed', async () => {
+ peek.mockReturnValue(tabsWith('tab-2'))
+ const tabContents = { loadURL: vi.fn(async () => {}), isDestroyed: () => false }
+ const add = vi
+ .spyOn(browserSession, 'addTab')
+ .mockReturnValue({ view: { webContents: tabContents } } as never)
+ const focus = vi.spyOn(browserSession, 'focusPageForUser').mockImplementation(() => {})
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(add).toHaveBeenCalledOnce()
+ expect(tabContents.loadURL).toHaveBeenCalledWith('https://signin.example.com/login')
+ expect(panelAction).not.toHaveBeenCalled()
+ add.mockRestore()
+ focus.mockRestore()
+ })
+
+ it('leaves the tabs alone when a newer continue retired the handoff mid-import', async () => {
+ vi.mocked(bringSignInBack).mockResolvedValueOnce({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: false,
+ })
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(panelAction).not.toHaveBeenCalled()
+ })
+
+ it('leaves the tabs alone when nothing came back', async () => {
+ vi.mocked(bringSignInBack).mockResolvedValueOnce({
+ result: { cookiesImported: 0, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: true,
+ })
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(panelAction).not.toHaveBeenCalled()
+ })
+ })
+
it('never imports Chrome cookies for a foreign origin', async () => {
const { invoke } = collectHandlers()
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 68727596dff..71a8d3a3c1a 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -57,6 +57,7 @@ import {
} from '@/main/browser-agent/driver'
import { isAgentWebContents } from '@/main/browser-agent/registry'
import {
+ activeTab,
addTab,
findInActiveTab,
focusPageForUser,
@@ -79,10 +80,15 @@ import {
revealCredential,
} from '@/main/browser-credentials'
import {
+ bringSignInBack,
+ cancelSignIn,
+ continueSignIn,
+ getPendingSignIn,
importChromeCookies,
importChromeData,
importChromePasswords,
listChromeImportProfiles,
+ migrateSignIn,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { listSites } from '@/main/browser-sites'
@@ -1177,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)
@@ -1513,6 +1520,78 @@ export function registerIpcHandlers(deps: IpcDeps): void {
)
},
},
+ // Continue a sign-in in the user's own browser (see the bridge's
+ // `continueSignIn`). The shell reads the address from the tab, never from
+ // the renderer; Sim's own pages ride the app session, so they are refused.
+ 'browser-import:continue-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ needsUserActivation: true,
+ denied: { error: 'unknown' },
+ handler: (sender, profileId, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (!scopeId || typeof profileId !== 'string') return { error: 'unknown' }
+ const tab = withBrowserScope(scopeId, () => activeTab())
+ const url = tab?.view.webContents.getURL() ?? ''
+ if (!tab || isAppOrigin(url, deps.appOrigin())) return { error: 'unsupported-page' }
+ return continueSignIn(scopeId, { profileId, tabId: tab.id, url })
+ },
+ },
+ // Imports the chat's pending sign-in, then reopens the handed-off page
+ // through the omnibox's navigation path, or in a new tab if it was closed.
+ 'browser-import:bring-sign-in-back': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ needsUserActivation: true,
+ denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
+ handler: async (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (!scopeId) return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }
+ const { result, handoff, current } = await bringSignInBack(scopeId)
+ if (!handoff || !current || result.cookiesImported === 0) return result
+ const tabOpen = withBrowserScope(scopeId, () =>
+ peekTabsState().tabs.some(({ tabId }) => tabId === handoff.tabId)
+ )
+ if (!tabOpen) {
+ withBrowserScope(scopeId, () => {
+ const tab = addTab()
+ void tab.view.webContents.loadURL(handoff.url).catch(() => {})
+ focusPageForUser(tab.view.webContents)
+ })
+ return result
+ }
+ await handlePanelAction(scopeId, { action: 'switch-tab', tabId: handoff.tabId })
+ .then(() => handlePanelAction(scopeId, { action: 'navigate', url: handoff.url }))
+ .catch(() => {})
+ return result
+ },
+ },
+ 'browser-import:cancel-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ denied: undefined,
+ handler: (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (scopeId) cancelSignIn(scopeId)
+ },
+ },
+ 'browser-import:pending-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ denied: null,
+ handler: (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ return scopeId ? getPendingSignIn(scopeId) : null
+ },
+ },
// Reported by the browser preload for the page it is running in. The
// origin it names is a claim: the fill coordinator re-checks it against
// the live URL before any password is read.
diff --git a/apps/desktop/src/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..ae232cf2a41
--- /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,122 @@
+'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: 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)
+ // 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)
+
+ const continueIn = useCallback(
+ async (profile: BrowserImportProfile) => {
+ try {
+ const result = await startHandoff(profile.id)
+ if (result.error) toast.error(CONTINUE_ERROR_MESSAGES[result.error])
+ } 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..dd787ae07a6
--- /dev/null
+++ b/apps/sim/hooks/queries/browser-import.ts
@@ -0,0 +1,96 @@
+'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)
+ },
+ // 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) }),
+ })
+}
+
+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..43c36eb48e0 100644
--- a/packages/desktop-bridge/src/index.ts
+++ b/packages/desktop-bridge/src/index.ts
@@ -501,6 +501,50 @@ 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. Neither is set when
+ * a cancel or a newer continue retired the handoff while the browser launched.
+ */
+export interface BrowserContinueSignInResult {
+ pending?: BrowserPendingSignIn
+ error?: BrowserContinueSignInError
}
/** Both halves of a combined Chrome import, each with its own outcome. */