diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 9a159e4c9..1da7698b2 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -216,6 +216,28 @@ should route through `browse` — `screenshot --selector` for visual output, `npm i puppeteer` and downloading a second Chromium that drifts out of version sync. One install to pin, one daemon's lifecycle to manage. +## Session Persistence (opt-in) + +By default the headless daemon's cookies and tab state die with it — a crash, +version auto-restart, or `browse stop` logs you out of everything (#778). +Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's +environment: the daemon then snapshots cookies + per-tab +URL/localStorage/sessionStorage to `/session-state.json` (0600) +every 30 seconds and at clean shutdown, and restores it on the next launch. + +Facts that matter: +- **Default OFF.** Cookies on disk are a real cost; the user opts in. +- **Headless only.** Headed mode's persistent Chromium profile already owns + its state; replaying tabs would clobber the user's window. +- **Never persisted:** loaded HTML and tab ownership — a tampered state file + cannot smuggle content past load-html's checks or forge ownership. Cookies + for localhost, `.internal`, and cloud-metadata addresses are dropped on + restore. +- **Corrupt state** is moved to `session-state.json.corrupt` (kept for + diagnosis) and the daemon boots fresh — persistence can never block a + launch. The boot log says which happened: `Session state restored: N + cookies / M tabs` or `fresh session`. + ## User Handoff When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor diff --git a/browse/src/server.ts b/browse/src/server.ts index 42130df84..2afe0f302 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -37,6 +37,10 @@ import { } from './token-registry'; import { validateTempPath } from './path-security'; import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config'; +import { + isSessionPersistEnabled, persistSessionState, restoreSessionState, + sessionPersistIntervalMs, SESSION_STATE_FILE, +} from './session-persist'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import { createSseEndpoint } from './sse-helpers'; import { initAuditLog, writeAuditEntry } from './audit'; @@ -1654,6 +1658,16 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { if (agentWatchdogInterval) clearInterval(agentWatchdogInterval); await flushBuffers(); + // Final session snapshot before the browser goes away (#778). Best + // effort: shutdown must never hang on a wedged page.evaluate. + if (isSessionPersistEnabled()) { + try { + await persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE)); + } catch (err: any) { + console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`); + } + } + await cfgBrowserManager.close(); cleanSingletonLocks(resolveChromiumProfile()); @@ -3078,6 +3092,36 @@ export async function start() { } else { await browserManager.launch(); } + + // ─── Opt-in session persistence (#778 class) ───────────────── + // BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last + // snapshot, then keep snapshotting on an interval. Launched mode only — + // the headed persistent profile owns its own state. The final snapshot + // at clean shutdown lives in buildFetchHandler's shutdown(). + if (isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') { + const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE); + try { + if (await restoreSessionState(browserManager, sessionStatePath)) { + const st = await browserManager.saveState(); + console.log(`[browse] Session state restored: ${st.cookies.length} cookies / ${st.pages.length} tabs (BROWSE_PERSIST_STATE=1)`); + } else { + console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)'); + } + } catch (err: any) { + console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`); + } + let persistWarned = false; + setInterval(() => { + persistSessionState(browserManager, sessionStatePath).catch((err: any) => { + // Warn once — a full disk must not spam the log every 30s, and a + // snapshot failure must never kill the daemon (R3). + if (!persistWarned) { + persistWarned = true; + console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`); + } + }); + }, sessionPersistIntervalMs()).unref(); + } } const startTime = Date.now(); diff --git a/browse/src/session-persist.ts b/browse/src/session-persist.ts new file mode 100644 index 000000000..3f4b9fd03 --- /dev/null +++ b/browse/src/session-persist.ts @@ -0,0 +1,150 @@ +/** + * Opt-in session-state persistence (#778, #2193, #1128, #1129). + * + * Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT. + * + * With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies + + * per-tab URL/localStorage/sessionStorage to /session-state.json + * on an interval and at clean shutdown, and restores it on the next launch. + * Kills the auth-lost-on-restart class: a crash or binary-version + * auto-restart no longer silently logs the user out of everything. + * + * Default OFF: cookies on disk (0600) are a real cost the user must opt + * into. Headed mode is excluded — the persistent Chromium profile already + * owns that state, and replaying tabs would clobber the user's window. + * + * Disk shape (version 1): { version, savedAt, cookies, pages[{url, + * isActive, storage}] }. loadedHtml and owner are NEVER persisted — same + * in-memory-only invariant as `state save|load` (meta-commands.ts): a + * tampered file must not smuggle HTML past load-html's checks or forge tab + * ownership. + */ + +import * as fs from 'fs'; +import type { BrowserManager, BrowserState } from './browser-manager'; +import { writeSecureFile } from './file-permissions'; +import { safeUnlinkQuiet } from './error-handling'; + +/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */ +function quarantineCorrupt(filePath: string): void { + try { + fs.renameSync(filePath, `${filePath}.corrupt`); + } catch { + safeUnlinkQuiet(filePath); + } +} + +export const SESSION_STATE_FILE = 'session-state.json'; +export const SESSION_STATE_VERSION = 1; + +/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */ +export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.BROWSE_PERSIST_STATE === '1'; +} + +/** Persist interval (ms). Env override exists for tests. */ +export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000; +} + +/** + * Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml, + * loadedHtmlWaitUntil, and owner (in-memory-only invariants). + */ +export function serializeSessionState(state: BrowserState): string { + return JSON.stringify({ + version: SESSION_STATE_VERSION, + savedAt: new Date().toISOString(), + cookies: state.cookies, + pages: state.pages.map((p) => ({ + url: p.url, + isActive: p.isActive, + storage: p.storage, + })), + }, null, 2); +} + +/** + * Same cookie hygiene as `state load` (meta-commands.ts, kept in sync by + * comment there): drop malformed cookies and internal-network domains a + * tampered file could use to reach localhost services or cloud metadata. + */ +export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] { + return cookies.filter((c: any) => { + if (typeof c !== 'object' || !c) return false; + if (typeof c.name !== 'string' || typeof c.value !== 'string') return false; + if (typeof c.domain !== 'string' || !c.domain) return false; + const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain; + if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false; + return true; + }) as BrowserState['cookies']; +} + +/** + * Parse + validate the on-disk shape into a BrowserState. Returns null for + * anything malformed (corrupt JSON, wrong version, missing arrays). + * loadedHtml/owner are stripped unconditionally even if present on disk. + */ +export function deserializeSessionState(raw: string): BrowserState | null { + let data: any; + try { + data = JSON.parse(raw); + } catch { + return null; + } + if (!data || data.version !== SESSION_STATE_VERSION) return null; + if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null; + return { + cookies: filterSessionCookies(data.cookies), + pages: data.pages.map((p: any) => ({ + url: typeof p?.url === 'string' ? p.url : '', + isActive: Boolean(p?.isActive), + storage: p?.storage && typeof p.storage === 'object' + ? { + localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {}, + sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {}, + } + : null, + // NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk. + })), + }; +} + +/** + * Snapshot the live session to disk (0600). No-op outside launched + * (headless) mode — the headed persistent profile owns its own state. + */ +export async function persistSessionState(bm: BrowserManager, filePath: string): Promise { + if (bm.getConnectionMode() !== 'launched') return; + const state = await bm.saveState(); + writeSecureFile(filePath, serializeSessionState(state)); +} + +/** + * Restore a persisted session into a freshly launched manager. Returns true + * when state was restored, false when there was nothing (or corrupt data — + * which is warned, deleted, and skipped rather than blocking launch). + * restoreState re-validates every URL before navigating. + */ +export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise { + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch (err: any) { + if (err?.code === 'ENOENT') return false; + throw err; + } + const state = deserializeSessionState(raw); + if (!state) { + // Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a + // 3-week-later bug report is reconstructable from the artifact. + console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`); + quarantineCorrupt(filePath); + return false; + } + // launch() opens one blank tab; replace it rather than restoring alongside. + await bm.closeAllPages(); + await bm.restoreState(state); + return true; +} diff --git a/browse/test/session-persist.test.ts b/browse/test/session-persist.test.ts new file mode 100644 index 000000000..6a4ab89dc --- /dev/null +++ b/browse/test/session-persist.test.ts @@ -0,0 +1,172 @@ +/** + * Opt-in session-state persistence (#778, #2193, #1128, #1129). + * + * Pins the leg of the browser-lifecycle contract that had no coverage: + * "shut down without losing live session state." Pre-fix, the headless + * daemon used non-persistent chromium.launch() with zero storage + * persistence — any crash or binary-version auto-restart silently lost all + * auth. These tests fail on the old tree (module absent, no wiring). + * + * Suites: + * 1. Pure serialize/deserialize/filter units (free, instant). + * 2. Real-Chromium round-trip: cookie + localStorage survive a full + * manager teardown + relaunch via persist/restore. + * 3. Static wiring tripwire: server.ts restores at launch, snapshots at + * shutdown, and the gate is BROWSE_PERSIST_STATE (default off). + */ + +import { describe, test, expect, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + serializeSessionState, deserializeSessionState, filterSessionCookies, + isSessionPersistEnabled, persistSessionState, restoreSessionState, +} from '../src/session-persist'; +import type { BrowserState } from '../src/browser-manager'; + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-persist-')); +afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); + +describe('session-persist units', () => { + test('config gate: default off, exactly "1" enables', () => { + expect(isSessionPersistEnabled({} as NodeJS.ProcessEnv)).toBe(false); + expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '0' } as any)).toBe(false); + expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: 'true' } as any)).toBe(false); + expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '1' } as any)).toBe(true); + }); + + test('serialize strips loadedHtml/owner, keeps cookies + storage', () => { + const state: BrowserState = { + cookies: [{ name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'Lax' } as any], + pages: [{ + url: 'https://example.com/app', + isActive: true, + storage: { localStorage: { k: 'v' }, sessionStorage: {} }, + loadedHtml: '', + loadedHtmlWaitUntil: 'load', + owner: 'agent-1', + }], + }; + const raw = serializeSessionState(state); + expect(raw).not.toContain('loadedHtml'); + expect(raw).not.toContain('evil'); + expect(raw).not.toContain('owner'); + const parsed = JSON.parse(raw); + expect(parsed.version).toBe(1); + expect(parsed.cookies[0].name).toBe('sid'); + expect(parsed.pages[0].storage.localStorage.k).toBe('v'); + }); + + test('deserialize rejects corrupt JSON, wrong version, missing arrays', () => { + expect(deserializeSessionState('not json{')).toBeNull(); + expect(deserializeSessionState('{"version":99,"cookies":[],"pages":[]}')).toBeNull(); + expect(deserializeSessionState('{"version":1,"cookies":{}}')).toBeNull(); + }); + + test('deserialize strips loadedHtml/owner even if tampered onto disk', () => { + const raw = JSON.stringify({ + version: 1, + cookies: [], + pages: [{ url: 'https://x.com', isActive: true, storage: null, loadedHtml: '

x

', owner: 'evil' }], + }); + const state = deserializeSessionState(raw)!; + expect((state.pages[0] as any).loadedHtml).toBeUndefined(); + expect((state.pages[0] as any).owner).toBeUndefined(); + }); + + test('cookie filter drops malformed + internal-network domains', () => { + const kept = filterSessionCookies([ + { name: 'ok', value: 'v', domain: 'example.com' }, + { name: 'bad1', value: 'v', domain: 'localhost' }, + { name: 'bad2', value: 'v', domain: '.corp.internal' }, + { name: 'bad3', value: 'v', domain: '169.254.169.254' }, + { name: 'bad4', value: 42, domain: 'example.com' }, + null, + ]); + expect(kept.map((c: any) => c.name)).toEqual(['ok']); + }); + + test('restoreSessionState: missing file → false, corrupt file → quarantined to .corrupt', async () => { + const bmNeverCalled = { closeAllPages() { throw new Error('must not restore'); } } as any; + expect(await restoreSessionState(bmNeverCalled, path.join(tmpRoot, 'nope.json'))).toBe(false); + const corrupt = path.join(tmpRoot, 'corrupt.json'); + fs.writeFileSync(corrupt, '{oops'); + expect(await restoreSessionState(bmNeverCalled, corrupt)).toBe(false); + expect(fs.existsSync(corrupt)).toBe(false); // moved aside, won't block every future launch + expect(fs.existsSync(`${corrupt}.corrupt`)).toBe(true); // forensic artifact kept (R3) + }); + + test('persistSessionState is a no-op in headed mode (profile owns state)', async () => { + const file = path.join(tmpRoot, 'headed.json'); + const bm = { + getConnectionMode: () => 'headed', + saveState() { throw new Error('must not snapshot headed session'); }, + } as any; + await persistSessionState(bm, file); + expect(fs.existsSync(file)).toBe(false); + }); +}); + +describe('session-persist round-trip (real Chromium)', () => { + test('cookie + localStorage + URL survive teardown → relaunch', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const { startTestServer } = await import('./test-server'); + const { server, url } = startTestServer(0); + const stateFile = path.join(tmpRoot, 'roundtrip.json'); + + const bm1 = new BrowserManager(); + await bm1.launch(); + try { + const page = bm1.getPage(); + await page.goto(`${url}/basic.html`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + document.cookie = 'session_marker=alive-after-restart; path=/'; + localStorage.setItem('auth_marker', 'still-logged-in'); + }); + await persistSessionState(bm1, stateFile); + } finally { + await bm1.close(); + } + + // File on disk is owner-only (cookies are secrets). + if (process.platform !== 'win32') { + expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600); + } + + const bm2 = new BrowserManager(); + await bm2.launch(); + try { + expect(await restoreSessionState(bm2, stateFile)).toBe(true); + const page = bm2.getPage(); + expect(page.url()).toContain('/basic.html'); + const marker = await page.evaluate(() => ({ + cookie: document.cookie, + auth: localStorage.getItem('auth_marker'), + })); + expect(marker.cookie).toContain('session_marker=alive-after-restart'); + expect(marker.auth).toBe('still-logged-in'); + } finally { + await bm2.close(); + server.stop(true); + } + }, 60_000); +}); + +describe('server wiring (static tripwire)', () => { + const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'src', 'server.ts'), 'utf-8'); + + test('start() restores and schedules interval snapshots behind the gate', () => { + expect(SERVER_SRC).toContain('isSessionPersistEnabled()'); + expect(SERVER_SRC).toContain('restoreSessionState(browserManager'); + expect(SERVER_SRC).toContain('sessionPersistIntervalMs()'); + }); + + test('shutdown() takes a final snapshot BEFORE closing the browser', () => { + const shutdownStart = SERVER_SRC.indexOf('async function shutdown('); + const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart); + const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart); + expect(persistAt).toBeGreaterThan(shutdownStart); + expect(closeAt).toBeGreaterThan(persistAt); + }); +});