Skill Tracer
Skills › gstack › browse › session-persist.ts

session-persist.ts

178 lines · browse/src/session-persist.ts at 3f79d81

1/**
2 * Opt-in session-state persistence (#778, #2193, #1128, #1129).
3 *
4 * Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
5 *
6 * With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies +
7 * per-tab URL/localStorage/sessionStorage to <stateDir>/session-state.json
8 * on an interval and at clean shutdown, and restores it on the next launch.
9 * Kills the auth-lost-on-restart class: a crash or binary-version
10 * auto-restart no longer silently logs the user out of everything.
11 *
12 * Default OFF: cookies on disk (0600) are a real cost the user must opt
13 * into. Headed mode is excluded — the persistent Chromium profile already
14 * owns that state, and replaying tabs would clobber the user's window.
15 *
16 * Disk shape (version 1): { version, savedAt, cookies, pages[{url,
17 * isActive, storage}] }. loadedHtml and owner are NEVER persisted — same
18 * in-memory-only invariant as `state save|load` (meta-commands.ts): a
19 * tampered file must not smuggle HTML past load-html's checks or forge tab
20 * ownership.
21 */
23import * as fs from 'fs';
24import type { BrowserManager, BrowserState } from './browser-manager';
25import { writeSecureFile } from './file-permissions';
26import { safeUnlinkQuiet } from './error-handling';
27import { classifyAddress } from './url-validation';
29/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */
30function quarantineCorrupt(filePath: string): void {
31 try {
32 fs.renameSync(filePath, `${filePath}.corrupt`);
33 } catch {
34 safeUnlinkQuiet(filePath);
35 }
38export const SESSION_STATE_FILE = 'session-state.json';
39export const SESSION_STATE_VERSION = 1;
41/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */
42export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
43 return env.BROWSE_PERSIST_STATE === '1';
46/** Persist interval (ms). Env override exists for tests. */
47export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
48 const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10);
49 return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
52/**
53 * Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml,
54 * loadedHtmlWaitUntil, and owner (in-memory-only invariants).
55 */
56export function serializeSessionState(state: BrowserState): string {
57 return JSON.stringify({
58 version: SESSION_STATE_VERSION,
59 savedAt: new Date().toISOString(),
60 cookies: state.cookies,
61 pages: state.pages.map((p) => ({
62 url: p.url,
63 isActive: p.isActive,
64 storage: p.storage,
65 })),
66 }, null, 2);
69/**
70 * True when a cookie domain points at an internal-network target a tampered
71 * state file could use to reach localhost services, *.internal hosts, or
72 * cloud metadata: `localhost`, `*.internal`, and any address literal that
73 * url-validation's classifyAddress calls loopback or blocked (127.0.0.0/8,
74 * ::1, 169.254.0.0/16, 100.100.100.200, fe80::/10, fc00::/7, and their
75 * mapped and numeric spellings). Leading-dot domain
76 * variants (`.127.0.0.1`) are normalized before matching. Single source of
77 * truth for the persistence restore path here AND `state load`
78 * (meta-commands.ts).
79 */
80export function isInternalCookieDomain(domain: string): boolean {
81 const d = domain.startsWith('.') ? domain.slice(1) : domain;
82 if (d === 'localhost' || d.endsWith('.internal')) return true;
83 const kind = classifyAddress(d);
84 return kind === 'blocked' || kind === 'loopback';
87/**
88 * Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
89 * cookies and internal-network domains (see isInternalCookieDomain).
90 */
91export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
92 return cookies.filter((c: any) => {
93 if (typeof c !== 'object' || !c) return false;
94 if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
95 if (typeof c.domain !== 'string' || !c.domain) return false;
96 return !isInternalCookieDomain(c.domain);
97 }) as BrowserState['cookies'];
100/**
101 * Parse + validate the on-disk shape into a BrowserState. Returns null for
102 * anything malformed (corrupt JSON, wrong version, missing arrays).
103 * loadedHtml/owner are stripped unconditionally even if present on disk.
104 */
105export function deserializeSessionState(raw: string): BrowserState | null {
106 let data: any;
107 try {
108 data = JSON.parse(raw);
109 } catch {
110 return null;
111 }
112 if (!data || data.version !== SESSION_STATE_VERSION) return null;
113 if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null;
114 return {
115 cookies: filterSessionCookies(data.cookies),
116 pages: data.pages.map((p: any) => ({
117 url: typeof p?.url === 'string' ? p.url : '',
118 isActive: Boolean(p?.isActive),
119 storage: p?.storage && typeof p.storage === 'object'
120 ? {
121 localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {},
122 sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {},
123 }
124 : null,
125 // NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk.
126 })),
127 };
130/**
131 * Snapshot the live session to disk (0600). No-op outside launched
132 * (headless) mode — the headed persistent profile owns its own state.
133 */
134export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
135 if (bm.getConnectionMode() !== 'launched') return;
136 const state = await bm.saveState();
137 // Atomic replace: stage the new snapshot beside the target, then rename
138 // over it. A crash mid-write must never destroy the previous good
139 // snapshot — surviving crashes is the point of this feature.
140 const tmpPath = `${filePath}.tmp`;
141 writeSecureFile(tmpPath, serializeSessionState(state));
142 try {
143 fs.renameSync(tmpPath, filePath);
144 } catch (err) {
145 safeUnlinkQuiet(tmpPath);
146 throw err;
147 }
150/**
151 * Restore a persisted session into a freshly launched manager. Returns the
152 * restored (already-filtered) state so callers can log counts without an
153 * extra saveState() round-trip, or null when there was nothing to restore
154 * (missing file, or corrupt data — which is warned, quarantined, and skipped
155 * rather than blocking launch). restoreState re-validates every URL before
156 * navigating.
157 */
158export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
159 let raw: string;
160 try {
161 raw = fs.readFileSync(filePath, 'utf-8');
162 } catch (err: any) {
163 if (err?.code === 'ENOENT') return null;
164 throw err;
165 }
166 const state = deserializeSessionState(raw);
167 if (!state) {
168 // Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a
169 // 3-week-later bug report is reconstructable from the artifact.
170 console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
171 quarantineCorrupt(filePath);
172 return null;
173 }
174 // launch() opens one blank tab; replace it rather than restoring alongside.
175 await bm.closeAllPages();
176 await bm.restoreState(state);
177 return state;