mirror of https://github.com/garrytan/gstack.git
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* True when a cookie domain points at an internal-network target a tampered
|
|
* state file could use to reach localhost services, *.internal hosts, or
|
|
* cloud metadata: `localhost`, `*.internal`, IPv4 loopback literals
|
|
* (127.0.0.0/8), IPv6 loopback (`::1`, `[::1]`), and link-local/metadata
|
|
* (169.254.0.0/16, which covers 169.254.169.254). Leading-dot domain
|
|
* variants (`.127.0.0.1`) are normalized before matching. Single source of
|
|
* truth for the persistence restore path here AND `state load`
|
|
* (meta-commands.ts).
|
|
*/
|
|
export function isInternalCookieDomain(domain: string): boolean {
|
|
const d = domain.startsWith('.') ? domain.slice(1) : domain;
|
|
if (d === 'localhost' || d.endsWith('.internal')) return true;
|
|
if (d === '::1' || d === '[::1]') return true; // IPv6 loopback
|
|
if (/^127\./.test(d)) return true; // IPv4 loopback block
|
|
if (/^169\.254\./.test(d)) return true; // link-local incl. cloud metadata
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
|
|
* cookies and internal-network domains (see isInternalCookieDomain).
|
|
*/
|
|
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;
|
|
return !isInternalCookieDomain(c.domain);
|
|
}) 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();
|
|
// Atomic replace: stage the new snapshot beside the target, then rename
|
|
// over it. A crash mid-write must never destroy the previous good
|
|
// snapshot — surviving crashes is the point of this feature.
|
|
const tmpPath = `${filePath}.tmp`;
|
|
writeSecureFile(tmpPath, serializeSessionState(state));
|
|
try {
|
|
fs.renameSync(tmpPath, filePath);
|
|
} catch (err) {
|
|
safeUnlinkQuiet(tmpPath);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore a persisted session into a freshly launched manager. Returns the
|
|
* restored (already-filtered) state so callers can log counts without an
|
|
* extra saveState() round-trip, or null when there was nothing to restore
|
|
* (missing file, or corrupt data — which is warned, quarantined, and skipped
|
|
* rather than blocking launch). restoreState re-validates every URL before
|
|
* navigating.
|
|
*/
|
|
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
|
|
let raw: string;
|
|
try {
|
|
raw = fs.readFileSync(filePath, 'utf-8');
|
|
} catch (err: any) {
|
|
if (err?.code === 'ENOENT') return null;
|
|
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 null;
|
|
}
|
|
// launch() opens one blank tab; replace it rather than restoring alongside.
|
|
await bm.closeAllPages();
|
|
await bm.restoreState(state);
|
|
return state;
|
|
}
|