Skip to content

Commit 5f00f19

Browse files
waleedlatif1claude
andauthored
feat(desktop): continue a passkey sign-in in your browser and bring the session back (#9515)
* 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 * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a4519ea commit 5f00f19

32 files changed

Lines changed: 1663 additions & 73 deletions

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ jobs:
115115
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json
116116
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
117117
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
118+
CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json
118119

119120
- name: Upload test results
120121
if: failure()
@@ -192,6 +193,7 @@ jobs:
192193
env:
193194
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
194195
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
196+
CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json
195197

196198
- name: Upload test results
197199
if: failure()

‎apps/desktop/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ tabs and popups select their session from the destination origin. Sign-out and a
171171
or server changes use the existing browser teardown. Browser views retain their own
172172
permission/download policy, SSRF guards, and minimal preload with no `simDesktop` API.
173173

174+
**Continue in Chrome** (browser menu, plus a hint on known sign-in hosts) opens the active
175+
https tab in a chosen Chromium-family profile and **Bring session back** imports only that
176+
page's registrable-domain cookies from it; the rationale and gating live on
177+
`SimDesktopBrowserImportApi.continueSignIn` in `@sim/desktop-bridge`.
178+
174179
Generated HTML remains inside its `allow-scripts` sandbox. The driver can inspect and
175180
interact with inline frames through isolated Chromium worlds, including out-of-process
176181
frames; it does not grant those pages access to the parent app. These shell changes

‎apps/desktop/e2e-durations.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"chat-panel.spec.ts": 31.08,
88
"check-report.spec.ts": 3.78,
99
"computer-use.spec.ts": 2.07,
10+
"continue-sign-in.spec.ts": 5.8,
1011
"desktop-tools-live-sim.spec.ts": 0.0,
1112
"local-files.spec.ts": 27.68,
1213
"packaged-smoke.spec.ts": 0.0,
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { mkdtempSync, rmSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
7+
import type { SimDesktopApi } from '@sim/desktop-bridge'
8+
import { recordCheck } from './check-report'
9+
10+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
11+
const SCOPE = 'continue-sign-in-fixture'
12+
13+
/**
14+
* "Continue in Chrome" through the real preload, IPC table, and browser session.
15+
*
16+
* The shell runs with an empty home directory, so no browser profile exists and
17+
* nothing here can launch the developer's browser or reach their Keychain. That
18+
* is also the unsupported case the web app must hide the feature for. The
19+
* import-back half against a real Chrome profile needs a Keychain item and is
20+
* covered by the importer's fixture-database tests instead.
21+
*/
22+
test.describe('continue a sign-in in another browser', () => {
23+
let server: Server
24+
let origin: string
25+
let site: string
26+
let home: string
27+
let userData: string
28+
let app: ElectronApplication | undefined
29+
30+
test.beforeAll(async () => {
31+
server = createServer((request, response) => {
32+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
33+
if (path === '/api/auth/get-session') {
34+
response.writeHead(200, { 'Content-Type': 'application/json' })
35+
response.end(
36+
JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
37+
)
38+
return
39+
}
40+
if (path.startsWith('/api/')) {
41+
response.writeHead(200, { 'Content-Type': 'application/json' })
42+
response.end('{}')
43+
return
44+
}
45+
if (request.headers.host?.startsWith('localhost')) {
46+
response.writeHead(200, { 'Content-Type': 'text/html' })
47+
response.end('<!doctype html><title>Sign in</title><h1>Sign in</h1>')
48+
return
49+
}
50+
response.writeHead(200, {
51+
'Content-Type': 'text/html',
52+
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
53+
})
54+
response.end(`<!doctype html><title>Sim fixture</title><h1>Continue fixture</h1>
55+
<button id="open">Open</button><button id="continue">Continue</button><button id="back">Back</button>
56+
<script>
57+
const api = globalThis.simDesktop
58+
const scope = ${JSON.stringify(SCOPE)}
59+
const record = (key) => (value) => { globalThis[key] = value }
60+
document.getElementById('open').onclick = () =>
61+
api.browserAgent.openUrl(location.origin.replace('127.0.0.1', 'localhost') + '/login', scope)
62+
.then(record('opened'))
63+
document.getElementById('continue').onclick = () =>
64+
api.browserImport.continueSignIn('chrome:Default', scope).then(record('continued'))
65+
document.getElementById('back').onclick = () =>
66+
api.browserImport.bringSignInBack(scope).then(record('broughtBack'))
67+
</script>`)
68+
})
69+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
70+
const address = server.address()
71+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
72+
origin = `http://127.0.0.1:${address.port}`
73+
site = `http://localhost:${address.port}`
74+
})
75+
76+
test.beforeEach(async () => {
77+
home = mkdtempSync(join(tmpdir(), 'sim-continue-home-'))
78+
userData = mkdtempSync(join(tmpdir(), 'sim-continue-e2e-'))
79+
app = await electron.launch({
80+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
81+
cwd: DESKTOP_DIR,
82+
env: {
83+
...process.env,
84+
HOME: home,
85+
SIM_DESKTOP_ORIGIN: origin,
86+
SIM_DESKTOP_USER_DATA: userData,
87+
},
88+
})
89+
const page = await app.firstWindow()
90+
await expect(page.getByRole('heading')).toHaveText('Continue fixture')
91+
await page.evaluate(async (scope) => {
92+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
93+
await api.browserAgent.activateScope(scope)
94+
api.browserAgent.setPanelBounds({ x: 0, y: 120, width: 800, height: 400 }, null, scope)
95+
}, SCOPE)
96+
})
97+
98+
test.afterEach(async () => {
99+
const testInfo = test.info()
100+
recordCheck(process.env.CONTINUE_SIGN_IN_REPORT_PATH, 'continue-sign-in', {
101+
name: testInfo.title,
102+
status: testInfo.status ?? 'unknown',
103+
durationMs: testInfo.duration,
104+
retry: testInfo.retry,
105+
})
106+
await app?.close()
107+
app = undefined
108+
rmSync(userData, { recursive: true, force: true })
109+
rmSync(home, { recursive: true, force: true })
110+
})
111+
112+
test.afterAll(async () => {
113+
await new Promise<void>((resolve, reject) =>
114+
server.close((error) => (error ? reject(error) : resolve()))
115+
)
116+
})
117+
118+
test('advertises the capability, but offers no browser when none is installed', async () => {
119+
const page = await app?.firstWindow()
120+
const surface = await page?.evaluate(async () => {
121+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
122+
return {
123+
continueSignIn: typeof api.browserImport?.continueSignIn,
124+
bringSignInBack: typeof api.browserImport?.bringSignInBack,
125+
pending: await api.browserImport?.getPendingSignIn?.('continue-sign-in-fixture'),
126+
profiles: await api.browserImport?.listChromeProfiles(),
127+
}
128+
})
129+
130+
expect(surface).toEqual({
131+
continueSignIn: 'function',
132+
bringSignInBack: 'function',
133+
pending: null,
134+
profiles: [],
135+
})
136+
})
137+
138+
test('refuses page script that acts without the user', async () => {
139+
const page = await app?.firstWindow()
140+
const results = await page?.evaluate(async (scope) => {
141+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
142+
return {
143+
continued: await api.browserImport?.continueSignIn?.('chrome:Default', scope),
144+
broughtBack: await api.browserImport?.bringSignInBack?.(scope),
145+
}
146+
}, SCOPE)
147+
148+
expect(results).toEqual({
149+
continued: { error: 'unknown' },
150+
broughtBack: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
151+
})
152+
})
153+
154+
test('reads the page from the tab itself and hands over only https', async () => {
155+
const page = await app?.firstWindow()
156+
if (!page || !app) throw new Error('App did not launch')
157+
const read = (key: string) => page.evaluate((name) => Reflect.get(globalThis, name), key)
158+
159+
await page.locator('#open').click()
160+
await expect.poll(() => read('opened')).toMatchObject({ activeTabId: expect.any(String) })
161+
await expect
162+
.poll(() =>
163+
app?.evaluate(
164+
({ webContents }, target) =>
165+
webContents.getAllWebContents().some((contents) => contents.getURL() === target),
166+
`${site}/login`
167+
)
168+
)
169+
.toBe(true)
170+
171+
await page.locator('#continue').click()
172+
await expect.poll(() => read('continued')).toEqual({ error: 'unsupported-page' })
173+
174+
await page.locator('#back').click()
175+
await expect
176+
.poll(() => read('broughtBack'))
177+
.toEqual({ cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' })
178+
})
179+
})

‎apps/desktop/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"electron-updater": "6.8.9",
4444
"micromatch": "4.0.8",
4545
"safe-regex2": "5.1.2",
46+
"tldts": "7.4.18",
4647
"pdf-lib": "1.17.1",
4748
"@sim/auth-ui": "workspace:*"
4849
},

‎apps/desktop/src/main/browser-agent/known-sessions.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
BrowserKnownSessionsState,
55
BrowserSessionEvidence,
66
} from '@sim/browser-protocol'
7+
import { isSameOrSubdomain } from '@/main/browser-import/site-scope'
78
import type { BrowserKnownSiteSetting, ConfigStore, DesktopSettings } from '@/main/config'
89

910
const MAX_STORED_SITES = 100
@@ -107,7 +108,7 @@ function recordsEqual(
107108

108109
function cookieMatchesHost(cookieDomain: string, hostname: string): boolean {
109110
const domain = normalizePublicHostname(cookieDomain)
110-
return domain !== null && (hostname === domain || hostname.endsWith(`.${domain}`))
111+
return domain !== null && isSameOrSubdomain(hostname, domain)
111112
}
112113

113114
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { execFile } from 'node:child_process'
2+
import { promisify } from 'node:util'
3+
4+
const execFileAsync = promisify(execFile)
5+
6+
const OPEN_TIMEOUT_MS = 15_000
7+
8+
/**
9+
* Opens `url` in one profile of an installed Chromium-family browser.
10+
*
11+
* `-n` launches a fresh instance whose command line Chromium's process
12+
* singleton forwards to the running browser; without it a running browser
13+
* drops `--args` and opens the page in whichever profile was last active.
14+
*/
15+
export async function openInBrowserProfile(
16+
bundleId: string,
17+
profileDirectory: string,
18+
url: string
19+
): Promise<void> {
20+
await execFileAsync(
21+
'/usr/bin/open',
22+
['-n', '-b', bundleId, '--args', `--profile-directory=${profileDirectory}`, url],
23+
{ timeout: OPEN_TIMEOUT_MS }
24+
)
25+
}

‎apps/desktop/src/main/browser-import/browser-sources.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface BrowserSource {
2626
userDataSegments: readonly string[]
2727
/** The login-Keychain generic-password item holding the Safe Storage key. */
2828
keychain: { service: string; account: string }
29+
/** The macOS bundle identifier, used to open a page in this browser. */
30+
bundleId: string
2931
}
3032

3133
export const BROWSER_SOURCES: readonly BrowserSource[] = [
@@ -34,42 +36,49 @@ export const BROWSER_SOURCES: readonly BrowserSource[] = [
3436
label: 'Chrome',
3537
userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'],
3638
keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
39+
bundleId: 'com.google.Chrome',
3740
},
3841
{
3942
id: 'arc',
4043
label: 'Arc',
4144
userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'],
4245
keychain: { service: 'Arc Safe Storage', account: 'Arc' },
46+
bundleId: 'company.thebrowser.Browser',
4347
},
4448
{
4549
id: 'dia',
4650
label: 'Dia',
4751
userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'],
4852
keychain: { service: 'Dia Safe Storage', account: 'Dia' },
53+
bundleId: 'company.thebrowser.dia',
4954
},
5055
{
5156
id: 'brave',
5257
label: 'Brave',
5358
userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
5459
keychain: { service: 'Brave Safe Storage', account: 'Brave' },
60+
bundleId: 'com.brave.Browser',
5561
},
5662
{
5763
id: 'edge',
5864
label: 'Microsoft Edge',
5965
userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'],
6066
keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
67+
bundleId: 'com.microsoft.edgemac',
6168
},
6269
{
6370
id: 'vivaldi',
6471
label: 'Vivaldi',
6572
userDataSegments: ['Library', 'Application Support', 'Vivaldi'],
6673
keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
74+
bundleId: 'com.vivaldi.Vivaldi',
6775
},
6876
{
6977
id: 'chromium',
7078
label: 'Chromium',
7179
userDataSegments: ['Library', 'Application Support', 'Chromium'],
7280
keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
81+
bundleId: 'org.chromium.Chromium',
7382
},
7483
] as const
7584

0 commit comments

Comments
 (0)