mirror of https://github.com/garrytan/gstack.git
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:
parent
f66aedb656
commit
e568f96df9
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue