mirror of https://github.com/garrytan/gstack.git
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:
parent
fa4e4c3f2c
commit
809434013e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue