|
| 1 | +import { join } from 'node:path' |
1 | 2 | import { |
| 3 | + type BrowserDataKind, |
2 | 4 | type BrowserOmniboxFocusMode, |
3 | 5 | type BrowserTabState, |
4 | 6 | type BrowserTabsState, |
|
7 | 9 | } from '@sim/browser-protocol' |
8 | 10 | import { createLogger } from '@sim/logger' |
9 | 11 | 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' |
11 | 13 | import { session as electronSession, nativeTheme, WebContentsView } from 'electron' |
12 | 14 | import { attachAgentContextMenu } from '@/main/browser-agent/context-menu' |
13 | 15 | import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions' |
@@ -46,6 +48,13 @@ export interface AgentSessionEvents { |
46 | 48 | onSessionClosed: () => void |
47 | 49 | /** A newly created tab's WebContents, for the driver to instrument. */ |
48 | 50 | 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 |
49 | 58 | /** The active tab changed (new tab, switch, close). */ |
50 | 59 | onActiveTabChanged: (contents: WebContents) => void |
51 | 60 | /** The tab list or active tab changed. */ |
@@ -213,6 +222,37 @@ export async function listAgentCookieSignals(): Promise<BrowserCookieSignal[]> { |
213 | 222 | return cookies.flatMap(({ domain }) => (typeof domain === 'string' ? [{ domain }] : [])) |
214 | 223 | } |
215 | 224 |
|
| 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 | + |
216 | 256 | /** |
217 | 257 | * Default-deny hardening for the agent partition: no permission grants of any |
218 | 258 | * kind, and downloads are cancelled (and surfaced to the driver) rather than |
@@ -291,6 +331,10 @@ function createTabView(): WebContentsView { |
291 | 331 | sandbox: true, |
292 | 332 | webSecurity: true, |
293 | 333 | 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'), |
294 | 338 | // Throttled by default: a hidden tab should idle. The one exception is |
295 | 339 | // the active tab while a tool waits on it, applied explicitly by |
296 | 340 | // applyActiveTabThrottling — never blanket across every tab. |
@@ -373,6 +417,13 @@ function createTabView(): WebContentsView { |
373 | 417 | // user-driven navigations that do not pass through the driver. |
374 | 418 | contents.on('did-navigate', persistPinnedTabs) |
375 | 419 | 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)) |
376 | 427 |
|
377 | 428 | events?.onTabCreated(contents) |
378 | 429 | return view |
@@ -840,6 +891,40 @@ export async function clearProfileStorage(): Promise<void> { |
840 | 891 | await ses.clearCache() |
841 | 892 | } |
842 | 893 |
|
| 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 | + |
843 | 928 | export function listTabs(): BrowserTabState[] { |
844 | 929 | restorePinnedTabs() |
845 | 930 | return tabs |
|
0 commit comments