diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2bc1c597d..c96530faa 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -254,6 +254,14 @@ export class BrowserManager { getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; } + /** + * The live browser context, or null before launch / after close. Exposed for + * the auto-cookie-persist layer (server.ts) to snapshot cookies on a + * checkpoint without reaching into private state. Callers must null-check — + * a checkpoint can fire between close() and the next launch(). + */ + getContext(): import('playwright').BrowserContext | null { return this.context; } + // ─── Watch Mode Methods ───────────────────────────────── isWatching(): boolean { return this.watching; } @@ -401,6 +409,27 @@ export class BrowserManager { if (this.customUserAgent) { contextOptions.userAgent = this.customUserAgent; } + + // Auto-cookie persistence (opt-in): seed the fresh context with previously + // persisted cookies so a device-trust cookie survives a daemon restart. + // Read-only + best-effort — the writer path (server.ts) owns the lock; a + // concurrent read of an atomically-written file is always a complete file. + // recreateContext() does NOT go through here (it restores in-memory state + // itself), so there's no double-restore. + try { + const { resolveConfig } = await import('./config'); + const { loadAutoCookieState, warnPlaintextOnce } = await import('./auto-cookie-persist'); + const cfg = resolveConfig(); + warnPlaintextOnce(cfg); + const restored = loadAutoCookieState(cfg); + if (restored && restored.cookies.length > 0) { + contextOptions.storageState = restored; + console.log(`[browse] Restored ${restored.cookies.length} persisted cookie(s) from auto-state`); + } + } catch { + // Never block launch on the persistence layer. + } + this.context = await this.browser.newContext(contextOptions); if (Object.keys(this.extraHeaders).length > 0) { diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acc..a1c6ea765 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -37,6 +37,13 @@ import { } from './token-registry'; import { validateTempPath } from './path-security'; import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config'; +import { + isAutoCookiePersistEnabled, + acquireLock as acquireAutoCookieLock, + releaseLock as releaseAutoCookieLock, + saveAutoCookieState, + cleanStaleTempFiles as cleanAutoCookieTempFiles, +} from './auto-cookie-persist'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import { createSseEndpoint } from './sse-helpers'; import { initAuditLog, writeAuditEntry } from './audit'; @@ -648,6 +655,47 @@ function idleCheckTick() { } const idleCheckInterval = setInterval(idleCheckTick, 60_000); +// ─── Auto-cookie persistence (opt-in) ────────────────────────── +// Checkpoints the live context's persistent cookies to a per-workspace state +// file so a device-trust cookie survives a daemon restart. Three triggers: +// (1) a periodic checkpoint below, (2) a debounced checkpoint after mutating +// commands (scheduleAutoCookieCheckpoint, called from handleCommandInternalImpl), +// and (3) a final flush in shutdown() before the browser closes. The crash +// path (emergencyCleanup) deliberately does NOT save — serializing a dying +// context risks a corrupt write; we rely on the most recent good checkpoint. +let autoCookieLockHeld = false; +let autoCookieLastHash: string | null = null; +let autoCookieDebounce: ReturnType | null = null; + +async function autoCookieCheckpoint(): Promise { + if (!autoCookieLockHeld) return; // never write without owning the lock + const res = await saveAutoCookieState(config, activeBrowserManager.getContext(), autoCookieLastHash); + autoCookieLastHash = res.hash; +} + +// Debounced checkpoint: coalesces bursts of mutating commands into one write a +// short while after the last one. unref'd so it never keeps the process alive. +function scheduleAutoCookieCheckpoint(): void { + if (!autoCookieLockHeld) return; + if (autoCookieDebounce) clearTimeout(autoCookieDebounce); + autoCookieDebounce = setTimeout(() => { + autoCookieDebounce = null; + void autoCookieCheckpoint(); + }, 1500); + if (typeof autoCookieDebounce.unref === 'function') autoCookieDebounce.unref(); +} + +// Periodic safety-net checkpoint (covers crash-before-shutdown where the +// debounce/flush never runs). unref'd — must not hold the daemon open. +const autoCookieInterval = setInterval(() => { void autoCookieCheckpoint(); }, 60_000); +if (typeof autoCookieInterval.unref === 'function') autoCookieInterval.unref(); + +// Commands that can mutate cookies/browser state and thus warrant a checkpoint. +const AUTO_COOKIE_MUTATING_COMMANDS = new Set([ + 'goto', 'click', 'fill', 'type', 'press', 'select', 'reload', 'back', 'forward', + 'chain', 'state', 'cookie-picker', 'set-cookie', +]); + // Test-only surface for server-factory.test.ts. Lets the dual-instance // idle-timer behavior be exercised deterministically without mutating // Date.now (which would interact with the leaked module-level setInterval). @@ -959,6 +1007,14 @@ async function handleCommandInternalImpl( const command = canonicalizeCommand(rawCommand); const isAliased = command !== rawCommand; + // Auto-cookie checkpoint (opt-in): a mutating command may have changed + // cookies. Schedule a debounced checkpoint — the 1.5s debounce means the + // capture runs after the command has actually applied. No-op unless the lock + // is held. Skip nested chain subcommands: the outer chain already scheduled. + if ((opts?.chainDepth ?? 0) === 0 && AUTO_COOKIE_MUTATING_COMMANDS.has(command)) { + scheduleAutoCookieCheckpoint(); + } + // ─── Recursion guard: reject nested chains ────────────────── if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) { return { status: 400, result: JSON.stringify({ error: 'Nested chain commands are not allowed' }), json: true }; @@ -1607,9 +1663,17 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch(); clearInterval(flushInterval); clearInterval(idleCheckInterval); + clearInterval(autoCookieInterval); + if (autoCookieDebounce) { clearTimeout(autoCookieDebounce); autoCookieDebounce = null; } if (agentWatchdogInterval) clearInterval(agentWatchdogInterval); await flushBuffers(); + // Final cookie checkpoint BEFORE the context closes — this is the graceful + // path all intentional shutdowns funnel through (idle timeout, SIGINT, + // /stop). Must run while the context still exists. Then release the lock. + await autoCookieCheckpoint(); + if (autoCookieLockHeld) { releaseAutoCookieLock(config); autoCookieLockHeld = false; } + await cfgBrowserManager.close(); cleanSingletonLocks(resolveChromiumProfile()); @@ -1640,6 +1704,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // reports connectionMode === 'launched'. activeBrowserManager = cfgBrowserManager; + // Acquire the per-workspace auto-cookie lock once, at daemon start. Holding + // it for the daemon's lifetime is what serializes concurrent daemons (e.g. + // separate git worktrees) — only the lock holder writes the shared slot. + // If a live peer owns it, disable auto-persistence for this daemon (loud) so + // two daemons never last-writer-wins each other's cookies. Reads still work + // without the lock (atomic writes make a concurrent read safe). + if (isAutoCookiePersistEnabled()) { + cleanAutoCookieTempFiles(config); + autoCookieLockHeld = acquireAutoCookieLock(config); + if (!autoCookieLockHeld) { + console.warn('[browse] Auto-cookie persistence disabled for this daemon: another instance holds the workspace lock.'); + } + } + // Wire the cfg-instance's onDisconnect to run shutdown when the user // closes the headed browser window. CHAIN any caller-provided handler // instead of overwriting it: gbrowser may have set its own onDisconnect