Skip to content

Commit 8f45b0c

Browse files
committed
feat(desktop): import Chrome browser data
1 parent 6839966 commit 8f45b0c

50 files changed

Lines changed: 7631 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/scripts/build.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,24 @@ async function run(): Promise<void> {
6868
entryPoints: ['src/preload/index.ts'],
6969
outfile: 'dist/preload.cjs',
7070
})
71-
await Promise.all([mainCtx.watch(), preloadCtx.watch()])
71+
// Separate from the main-window preload: this one is injected into
72+
// untrusted pages in the built-in browser and must stay minimal.
73+
const browserPreloadCtx = await context({
74+
...common,
75+
entryPoints: ['src/preload/browser/index.ts'],
76+
outfile: 'dist/browser-preload.cjs',
77+
})
78+
await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()])
7279
return
7380
}
7481
await Promise.all([
7582
build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
7683
build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),
84+
build({
85+
...common,
86+
entryPoints: ['src/preload/browser/index.ts'],
87+
outfile: 'dist/browser-preload.cjs',
88+
}),
7789
])
7890
}
7991

apps/desktop/src/main/browser-agent/driver.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe('executeTool', () => {
5757
onPageState: vi.fn(),
5858
onTabsState: vi.fn(),
5959
onSessionStatus: vi.fn(),
60+
onFillAvailability: vi.fn(),
6061
},
6162
() => win
6263
)
@@ -108,6 +109,7 @@ describe('executeTool', () => {
108109
onPageState: vi.fn(),
109110
onTabsState: vi.fn(),
110111
onSessionStatus: vi.fn(),
112+
onFillAvailability: vi.fn(),
111113
},
112114
() => win
113115
)
@@ -151,7 +153,12 @@ describe('credential protection', () => {
151153
async function openPage() {
152154
const win = new BrowserWindow()
153155
driver.initDriver(
154-
{ onPageState: vi.fn(), onTabsState: vi.fn(), onSessionStatus: vi.fn() },
156+
{
157+
onPageState: vi.fn(),
158+
onTabsState: vi.fn(),
159+
onSessionStatus: vi.fn(),
160+
onFillAvailability: vi.fn(),
161+
},
155162
() => win
156163
)
157164
await driver.executeTool('browser_open_tab', {})

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
* is bounded by a watchdog so the Sim side always gets a response instead of
1616
* waiting out its own timeout against silence.
1717
*/
18-
import type {
19-
BrowserKnownSessionsState,
20-
BrowserPageState,
21-
BrowserPanelAction,
22-
BrowserTabsState,
23-
BrowserToolName,
18+
import {
19+
BROWSER_DATA_KINDS,
20+
type BrowserDataKind,
21+
type BrowserKnownSessionsState,
22+
type BrowserPageState,
23+
type BrowserPanelAction,
24+
type BrowserTabsState,
25+
type BrowserToolName,
2426
} from '@sim/browser-protocol'
2527
import { createLogger } from '@sim/logger'
2628
import { getErrorMessage } from '@sim/utils/errors'
@@ -53,6 +55,7 @@ import {
5355
} from '@/main/browser-agent/page-functions'
5456
import * as session from '@/main/browser-agent/session'
5557
import { checkAgentUrl } from '@/main/browser-agent/url-guard'
58+
import { clearCredentials, fillCoordinator, initFillCoordinator } from '@/main/browser-credentials'
5659
import type { ConfigStore } from '@/main/config'
5760

5861
const logger = createLogger('BrowserAgentDriver')
@@ -76,6 +79,8 @@ export interface DriverCallbacks {
7679
onPageState: (state: BrowserPageState) => void
7780
onTabsState: (state: BrowserTabsState) => void
7881
onSessionStatus: (alive: boolean) => void
82+
/** Whether the active tab shows a login form Sim holds a credential for. */
83+
onFillAvailability: (available: boolean) => void
7984
}
8085

8186
let driverCallbacks: DriverCallbacks | null = null
@@ -185,13 +190,23 @@ export function initDriver(
185190
): void {
186191
driverCallbacks = callbacks
187192
knownSessions = config ? new BrowserKnownSessionRegistry(config) : null
193+
initFillCoordinator({
194+
getActiveContents: () => session.activeTab()?.view.webContents ?? null,
195+
onAvailabilityChanged: (available) => callbacks.onFillAvailability(available),
196+
})
188197
session.initSession(
189198
{
190199
onSessionClosed: () => {
191200
driverCallbacks?.onSessionStatus(false)
192201
},
193202
onTabCreated: instrumentTab,
194-
onActiveTabChanged: pushPageState,
203+
onTabNavigated: (contents) => fillCoordinator()?.noteNavigation(contents),
204+
onTabClosed: (contents) => fillCoordinator()?.forget(contents),
205+
onActiveTabChanged: (contents) => {
206+
pushPageState(contents)
207+
// The fill affordance belongs to whichever page is in front.
208+
void fillCoordinator()?.refreshAvailability()
209+
},
195210
onTabsChanged: pushTabsState,
196211
onTabThemeChanged: (contents, theme) => {
197212
void cdp.setColorScheme(contents, theme).catch((error) => {
@@ -223,13 +238,32 @@ export async function getKnownSessions(): Promise<BrowserKnownSessionsState> {
223238
}
224239

225240
/**
226-
* Everything the embedded browser remembers about the signed-in user, cleared
227-
* as one unit on sign-out. Lives here because the browsing-trail registry and
228-
* the session profile are owned by different modules.
241+
* Cookies, site storage, cache, and the remembered browsing trail.
242+
*
243+
* Saved passwords are deliberately NOT touched. "Clear browsing data" is about
244+
* signing out of websites, and a user who wanted to erase their password vault
245+
* would have to say so separately — silently taking their credentials with it
246+
* would be a destructive surprise.
247+
*/
248+
export async function clearBrowsingData(
249+
kinds: readonly BrowserDataKind[] = BROWSER_DATA_KINDS
250+
): Promise<void> {
251+
// The remembered browsing trail is the local mirror of the cookie jar, so it
252+
// goes when cookies do and stays when they do not.
253+
if (kinds.includes('cookies')) knownSessions?.clear()
254+
await session.clearAgentData(kinds)
255+
}
256+
257+
/**
258+
* Everything the embedded browser holds for the signed-in user, including the
259+
* credential vault. This is the Sim sign-out path: a different account signing
260+
* in on the same machine must not inherit the previous user's sessions or
261+
* passwords.
229262
*/
230263
export async function clearBrowserProfile(): Promise<void> {
231264
knownSessions?.clear()
232265
await session.clearProfileStorage()
266+
await clearCredentials()
233267
}
234268

235269
function str(params: Record<string, unknown>, key: string): string | undefined {

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ async function freshSession(
6161
onTabsChanged: vi.fn(),
6262
onTabThemeChanged: vi.fn(),
6363
onDownloadBlocked: vi.fn(),
64+
onTabNavigated: vi.fn(),
65+
onTabClosed: vi.fn(),
6466
...eventOverrides,
6567
},
6668
() => win,
@@ -671,6 +673,8 @@ describe('browser-agent session', () => {
671673
onTabsChanged: vi.fn(),
672674
onTabThemeChanged: vi.fn(),
673675
onDownloadBlocked: vi.fn(),
676+
onTabNavigated: vi.fn(),
677+
onTabClosed: vi.fn(),
674678
},
675679
() => replacement
676680
)
@@ -1160,3 +1164,60 @@ describe('reopening a closed tab', () => {
11601164
expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled()
11611165
})
11621166
})
1167+
1168+
describe('importAgentCookies', () => {
1169+
/** Points the mocked partition at a cookie jar and returns its `set` spy. */
1170+
async function withCookieJar(
1171+
set: ReturnType<typeof vi.fn>
1172+
): Promise<typeof import('@/main/browser-agent/session')> {
1173+
vi.mocked(electronSession.fromPartition).mockReturnValue({
1174+
cookies: { set },
1175+
} as unknown as ReturnType<typeof electronSession.fromPartition>)
1176+
vi.resetModules()
1177+
return import('@/main/browser-agent/session')
1178+
}
1179+
1180+
const cookie = (name: string) => ({
1181+
url: 'https://example.com/',
1182+
name,
1183+
value: 'v',
1184+
path: '/',
1185+
secure: true,
1186+
httpOnly: true,
1187+
sameSite: 'lax' as const,
1188+
})
1189+
1190+
it('writes every cookie into the dedicated browser profile', async () => {
1191+
const set = vi.fn(async () => {})
1192+
const session = await withCookieJar(set)
1193+
1194+
const result = await session.importAgentCookies([cookie('a'), cookie('b')])
1195+
1196+
expect(result).toEqual({ imported: 2, failed: 0 })
1197+
expect(electronSession.fromPartition).toHaveBeenCalledWith('persist:sim-browser-agent')
1198+
expect(set).toHaveBeenCalledTimes(2)
1199+
expect(set).toHaveBeenNthCalledWith(1, cookie('a'))
1200+
})
1201+
1202+
it('counts a rejected cookie without losing the rest', async () => {
1203+
// Chromium refuses cookies whose attributes are inconsistent. That
1204+
// rejection must cost one cookie, not the whole import.
1205+
const set = vi.fn(async (details: { name: string }) => {
1206+
if (details.name === 'bad') throw new Error('Failed to set cookie')
1207+
})
1208+
const session = await withCookieJar(set)
1209+
1210+
const result = await session.importAgentCookies([cookie('a'), cookie('bad'), cookie('c')])
1211+
1212+
expect(result).toEqual({ imported: 2, failed: 1 })
1213+
expect(set).toHaveBeenCalledTimes(3)
1214+
})
1215+
1216+
it('does nothing when there is nothing to import', async () => {
1217+
const set = vi.fn(async () => {})
1218+
const session = await withCookieJar(set)
1219+
1220+
await expect(session.importAgentCookies([])).resolves.toEqual({ imported: 0, failed: 0 })
1221+
expect(set).not.toHaveBeenCalled()
1222+
})
1223+
})

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { join } from 'node:path'
12
import {
3+
type BrowserDataKind,
24
type BrowserOmniboxFocusMode,
35
type BrowserTabState,
46
type BrowserTabsState,
@@ -7,7 +9,7 @@ import {
79
} from '@sim/browser-protocol'
810
import { createLogger } from '@sim/logger'
911
import { getErrorMessage } from '@sim/utils/errors'
10-
import type { BrowserWindow, Input, Session, WebContents } from 'electron'
12+
import type { BrowserWindow, CookiesSetDetails, Input, Session, WebContents } from 'electron'
1113
import { session as electronSession, nativeTheme, WebContentsView } from 'electron'
1214
import { attachAgentContextMenu } from '@/main/browser-agent/context-menu'
1315
import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions'
@@ -46,6 +48,13 @@ export interface AgentSessionEvents {
4648
onSessionClosed: () => void
4749
/** A newly created tab's WebContents, for the driver to instrument. */
4850
onTabCreated: (contents: WebContents) => void
51+
/**
52+
* A tab navigated, including in-page. Anything bound to the previous
53+
* document — notably a pending credential fill — must be invalidated.
54+
*/
55+
onTabNavigated: (contents: WebContents) => void
56+
/** A tab's WebContents is going away, so per-tab state can be dropped. */
57+
onTabClosed: (contents: WebContents) => void
4958
/** The active tab changed (new tab, switch, close). */
5059
onActiveTabChanged: (contents: WebContents) => void
5160
/** The tab list or active tab changed. */
@@ -213,6 +222,37 @@ export async function listAgentCookieSignals(): Promise<BrowserCookieSignal[]> {
213222
return cookies.flatMap(({ domain }) => (typeof domain === 'string' ? [{ domain }] : []))
214223
}
215224

225+
/**
226+
* Writes imported cookies into the dedicated profile.
227+
*
228+
* Electron's cookie API is deliberately the only writer: Chromium owns the
229+
* destination store's format, and editing that SQLite file directly would
230+
* couple Sim to internals it does not control and risk corrupting the profile.
231+
* It is also the enforcement point — Chromium rejects a cookie whose
232+
* attributes are inconsistent (`SameSite=None` without `Secure`, a domain the
233+
* URL cannot set), so a row that would only import under weaker terms fails
234+
* here and is counted rather than being quietly relaxed.
235+
*
236+
* Failures are per-cookie: one rejected cookie must not cost the user the
237+
* rest. Nothing about a cookie is logged.
238+
*/
239+
export async function importAgentCookies(
240+
cookies: CookiesSetDetails[]
241+
): Promise<{ imported: number; failed: number }> {
242+
const jar = electronSession.fromPartition(AGENT_PARTITION).cookies
243+
let imported = 0
244+
let failed = 0
245+
for (const cookie of cookies) {
246+
try {
247+
await jar.set(cookie)
248+
imported += 1
249+
} catch {
250+
failed += 1
251+
}
252+
}
253+
return { imported, failed }
254+
}
255+
216256
/**
217257
* Default-deny hardening for the agent partition: no permission grants of any
218258
* kind, and downloads are cancelled (and surfaced to the driver) rather than
@@ -291,6 +331,10 @@ function createTabView(): WebContentsView {
291331
sandbox: true,
292332
webSecurity: true,
293333
webviewTag: false,
334+
// A minimal, isolated preload that reports login-form presence and
335+
// performs user-authorized credential fills. It exposes nothing to the
336+
// page, and runs in the top-level frame only.
337+
preload: join(__dirname, 'browser-preload.cjs'),
294338
// Throttled by default: a hidden tab should idle. The one exception is
295339
// the active tab while a tool waits on it, applied explicitly by
296340
// applyActiveTabThrottling — never blanket across every tab.
@@ -373,6 +417,13 @@ function createTabView(): WebContentsView {
373417
// user-driven navigations that do not pass through the driver.
374418
contents.on('did-navigate', persistPinnedTabs)
375419
contents.on('did-navigate-in-page', persistPinnedTabs)
420+
// Both document loads and same-document route changes invalidate anything
421+
// bound to the previous page: a single-page app can replace a login form
422+
// with another site's UI without ever loading a new document.
423+
contents.on('did-start-navigation', () => events?.onTabNavigated(contents))
424+
contents.on('did-navigate', () => events?.onTabNavigated(contents))
425+
contents.on('did-navigate-in-page', () => events?.onTabNavigated(contents))
426+
contents.on('destroyed', () => events?.onTabClosed(contents))
376427

377428
events?.onTabCreated(contents)
378429
return view
@@ -840,6 +891,40 @@ export async function clearProfileStorage(): Promise<void> {
840891
await ses.clearCache()
841892
}
842893

894+
/**
895+
* Site storage other than cookies. Named explicitly rather than by omission so
896+
* a new Chromium storage type is not silently swept into "site data" — the
897+
* whole-profile wipe is the one that deliberately takes everything.
898+
*/
899+
const SITE_DATA_STORAGES = [
900+
'filesystem',
901+
'indexdb',
902+
'localstorage',
903+
'shadercache',
904+
'websql',
905+
'serviceworkers',
906+
'cachestorage',
907+
] as const
908+
909+
/**
910+
* Erases selected kinds of browsing data without ending the session.
911+
*
912+
* Unlike {@link clearProfileStorage} this leaves tabs open and the pinned strip
913+
* intact: the user asked to clear data, not to close their browser. Saved
914+
* passwords live in a separate vault and are never touched here.
915+
*/
916+
export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise<void> {
917+
const ses = electronSession.fromPartition(AGENT_PARTITION)
918+
const storages: string[] = []
919+
if (kinds.includes('cookies')) storages.push('cookies')
920+
if (kinds.includes('site-data')) storages.push(...SITE_DATA_STORAGES)
921+
922+
if (storages.length > 0) {
923+
await ses.clearStorageData({ storages } as Parameters<Session['clearStorageData']>[0])
924+
}
925+
if (kinds.includes('cache')) await ses.clearCache()
926+
}
927+
843928
export function listTabs(): BrowserTabState[] {
844929
restorePinnedTabs()
845930
return tabs

0 commit comments

Comments
 (0)