feat(browse): opt-in session persistence — auth survives daemon restarts (#778, #2193)

BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/
sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd
interval and at clean shutdown, and restores on the next launch — killing
the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129).

Security invariants mirror state save|load: loadedHtml and owner are never
persisted and never accepted from disk; restored cookies pass the same
hygiene filter (localhost/.internal/metadata domains dropped); restoreState
re-validates every URL. Default OFF; headed mode excluded (the persistent
profile owns that state). Hardened past the fork's shape per review R3:
corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one
log line), snapshot failures warn once and never kill the daemon, and the
boot log reports restored counts or fresh-session status.

Module + 10 tests ported (MIT header retained); server wiring at launch,
interval, and shutdown; skill docs section added (regen rides the cluster
regen commit).

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 12:59:34 -07:00
parent fa4e4c3f2c
commit 809434013e
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 388 additions and 0 deletions

View File

@ -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 `<stateDir>/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

View File

@ -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();

View File

@ -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 <stateDir>/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<void> {
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<boolean> {
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;
}

View File

@ -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: '<script>evil</script>',
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: '<h1>x</h1>', 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);
});
});