feat(browse): wire auto-cookie persistence into launch + daemon lifecycle

Connects the persistence module to the daemon:
- browser-manager.ts: getContext() accessor, and launch() seeds the fresh
  context via newContext({ storageState }) from the persisted cookies BEFORE
  the first tab — a restart transparently restores the device cookie.
  recreateContext() is untouched (it restores in-memory state itself), so
  there is no double-restore.
- server.ts: acquire the per-workspace lock once at daemon start (a live peer
  holding it disables persistence for this daemon, loudly, instead of racing).
  Three save triggers — a debounced checkpoint after mutating commands, a
  periodic safety-net checkpoint, and a final flush in shutdown() right before
  the context closes (the graceful path all intentional shutdowns funnel
  through). The crash path (emergencyCleanup) deliberately never saves, to
  avoid serializing a dying context. Lock is released on shutdown.

All triggers are no-ops unless BROWSE_AUTO_COOKIE_PERSIST is set, so default
behavior is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
현성 2026-07-07 19:17:17 +09:00
parent f66aedb656
commit e568f96df9
2 changed files with 107 additions and 0 deletions

View File

@ -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) {

View File

@ -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<typeof setTimeout> | null = null;
async function autoCookieCheckpoint(): Promise<void> {
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