import { randomUUID } from "node:crypto"; import { readdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import { z } from "zod"; import { writeFileAtomic } from "./atomic.ts"; import { browserDaemonFile, browserProfilesRoot, closeBrowserSession, runningBrowserSessionNames } from "./browser-engine.ts"; import { orphanedBrowsers, stopBrowserProcesses } from "./browser-processes.ts"; import { browserRuntimeEnv } from "./browser-runtime.ts"; const BOT_ID = /^[A-Za-z0-9_-]{1,120}$/; const PROFILE_ID = /^[a-z0-9_-]{1,40}$/; const PROFILE_PARTITION_ID = /^[A-Za-z0-9_-]{1,40}$/; const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const MAX_PENDING = 512; const missingFileErrorSchema = z.looseObject({ code: z.literal("ENOENT") }); const browserCleanupTargetSchema = z.discriminatedUnion("kind", [ z.object({ requestId: z.string().regex(REQUEST_ID), kind: z.literal("bot"), id: z.string().regex(BOT_ID), phase: z.enum(["prepared", "committed"]), }).strict(), z.object({ requestId: z.string().regex(REQUEST_ID), kind: z.literal("profile"), id: z.string().regex(PROFILE_ID).refine((id) => id !== "guest"), partitionId: z.string().regex(PROFILE_PARTITION_ID).refine((id) => id !== "guest"), phase: z.enum(["prepared", "committed"]), }).strict(), ]); const browserCleanupJournalSchema = z.array(browserCleanupTargetSchema).max(MAX_PENDING).superRefine((entries, ctx) => { const requestIds = new Set(); for (const [index, entry] of entries.entries()) { if (!requestIds.has(entry.requestId)) { requestIds.add(entry.requestId); continue; } ctx.addIssue({ code: "custom", path: [index, "requestId"], message: "duplicate browser cleanup request id", }); } }); const browserCleanupResultSchema = z.object({ type: z.literal("openmausbot:browser-lifecycle-result"), requestId: z.string().regex(REQUEST_ID), ok: z.boolean(), }).strict(); export type BrowserCleanupKind = "bot" | "profile"; export type BrowserCleanupRequest = z.infer; export type BrowserCleanupWireRequest = { type: "openmausbot:browser-bot-deleted" | "openmausbot:browser-profile-deleted"; requestId: string; botId?: string; partitionId?: string; }; export interface BrowserCleanupIncomingMessage { type?: string; requestId?: string; ok?: boolean; } type CleanupJournalWriter = (path: string, data: string, options: { mode?: number }) => void; /** Bookkeeping failures are reported only after the mandatory runtime effects ran, so a revoked bearer or fleet never stays live until restart. */ export async function finalizeBrowserCleanupMutation(options: { requests: readonly BrowserCleanupRequest[]; referenceError?: unknown; commit: (request: BrowserCleanupRequest) => BrowserCleanupRequest; ensure: (request: BrowserCleanupRequest) => Promise; mandatory: () => Promise; }): Promise<{ value: T; acknowledgements: boolean[] }> { let firstError: unknown | null = options.referenceError ?? null; const pendingAcknowledgements: Array> = []; if (firstError === null) { for (const request of options.requests) { try { const committed = options.commit(request); pendingAcknowledgements.push(options.ensure(committed)); } catch (error) { firstError = error; break; } } } let value!: T; try { value = await options.mandatory(); } catch (error) { if (firstError === null) firstError = error; } const settled = await Promise.allSettled(pendingAcknowledgements); const acknowledgements = settled.map((result) => result.status === "fulfilled" && result.value); const rejected = settled.find((result): result is PromiseRejectedResult => result.status === "rejected"); if (firstError === null && rejected) firstError = rejected.reason; if (firstError !== null) throw firstError; return { value, acknowledgements }; } function unavailableJournalError(error: Error): Error & { status: number } { return Object.assign(new Error( "The browser cleanup journal could not be read safely. Browser profile reuse and deletion are blocked " + `until the journal is repaired (${error.message}).`, ), { status: 503, cause: error }); } export function requireBrowserCleanupAcknowledged(ok: boolean, target: string): void { if (ok) return; const error = Object.assign(new Error( `${target} was removed, but OpenMausBot could not confirm its local browser data was erased. ` + "Restart the desktop app before reusing it; cleanup will retry automatically.", ), { status: 503 }); throw error; } type Waiter = { resolve: (ok: boolean) => void; timer: ReturnType; }; function validTarget(kind: BrowserCleanupKind, id: string, partitionId: string): boolean { return kind === "bot" ? BOT_ID.test(id) : PROFILE_ID.test(id) && id !== "guest" && PROFILE_PARTITION_ID.test(partitionId) && partitionId !== "guest"; } /** Crash-safe handoff to Electron: a deletion is journaled as prepared before its config/store mutation and committed after; * only committed entries are dispatched, so a crash can block id reuse but never trigger a wipe from an ambiguous snapshot. */ export class BrowserCleanupCoordinator { readonly #file: string; readonly #send: (message: BrowserCleanupWireRequest) => boolean; readonly #timeoutMs: number; readonly #retryMs: readonly number[]; readonly #write: CleanupJournalWriter; readonly #pending = new Map(); readonly #waiters = new Map(); readonly #inflight = new Map>(); readonly #retryTimers = new Map>(); #loadFailure: (Error & { status: number }) | null = null; constructor(options: { file: string; send: (message: BrowserCleanupWireRequest) => boolean; timeoutMs?: number; retryMs?: readonly number[]; write?: CleanupJournalWriter; }) { this.#file = options.file; this.#send = options.send; this.#timeoutMs = Math.max(10, options.timeoutMs ?? 10_000); this.#retryMs = options.retryMs?.length ? options.retryMs : [1_000, 5_000, 30_000, 120_000]; this.#write = options.write ?? writeFileAtomic; this.#load(); } #load(): void { try { const raw: unknown = JSON.parse(readFileSync(this.#file, "utf8")); const journal = browserCleanupJournalSchema.safeParse(raw); if (!journal.success) { throw new Error(`invalid browser cleanup journal: ${journal.error.issues[0]?.message ?? "invalid entry"}`); } for (const request of journal.data) this.#pending.set(request.requestId, request); } catch (caught) { // Only a missing journal is empty; replacing any unreadable state could resurrect a deleted login partition. if (missingFileErrorSchema.safeParse(caught).success) return; const error = caught instanceof Error ? caught : new Error(String(caught)); this.#loadFailure = unavailableJournalError(error); } } #assertHealthy(): void { if (this.#loadFailure) throw this.#loadFailure; } #save(): void { this.#assertHealthy(); this.#write(this.#file, JSON.stringify([...this.#pending.values()], null, 2), { mode: 0o600 }); } prepare(kind: BrowserCleanupKind, id: string, partitionId = id): BrowserCleanupRequest { this.#assertHealthy(); if (!validTarget(kind, id, partitionId)) throw new Error(`invalid browser ${kind} cleanup target`); const existing = [...this.#pending.values()].find((request) => request.kind === kind && request.id === id); if (existing) return existing; if (this.#pending.size >= MAX_PENDING) throw new Error("too many pending browser data cleanups"); const request: BrowserCleanupRequest = kind === "bot" ? { requestId: randomUUID(), kind, id, phase: "prepared" } : { requestId: randomUUID(), kind, id, partitionId, phase: "prepared" }; this.#pending.set(request.requestId, request); try { this.#save(); } catch (error) { this.#pending.delete(request.requestId); throw error; } return request; } /** This marker is the only authority startup replay uses: in-memory loaders recover parse failures as empty state. */ commit(request: BrowserCleanupRequest): BrowserCleanupRequest { this.#assertHealthy(); const current = this.#pending.get(request.requestId); if (!current || current.kind !== request.kind || current.id !== request.id) { throw new Error("unknown browser cleanup request"); } if (current.phase === "committed") return current; const committed = { ...current, phase: "committed" as const } satisfies BrowserCleanupRequest; this.#pending.set(request.requestId, committed); try { this.#save(); } catch (error) { this.#pending.set(request.requestId, current); throw error; } return committed; } abort(request: BrowserCleanupRequest): void { this.#assertHealthy(); if (!this.#pending.has(request.requestId)) return; if (this.#pending.get(request.requestId)?.phase === "committed") { throw new Error("cannot abort a committed browser cleanup"); } this.#pending.delete(request.requestId); try { this.#save(); } catch (error) { this.#pending.set(request.requestId, request); throw error; } } pending(): BrowserCleanupRequest[] { this.#assertHealthy(); return [...this.#pending.values()]; } hasPendingProfile(profileId: string, partitionId = profileId): boolean { this.#assertHealthy(); const foldedPartitionId = partitionId.toLowerCase(); return [...this.#pending.values()].some((request) => request.kind === "profile" && (request.id === profileId || request.partitionId.toLowerCase() === foldedPartitionId)); } /** Prepared entries are excluded: their primary config deletion may not have committed. */ committedProfileIds(): string[] { this.#assertHealthy(); return [...new Set( [...this.#pending.values()] .filter((request): request is Extract => request.kind === "profile" && request.phase === "committed") .map((request) => request.id), )]; } /** A late success still clears the durable journal even after the request's timeout fired. */ receive(message: BrowserCleanupIncomingMessage | undefined): boolean { if (message?.type !== "openmausbot:browser-lifecycle-result") return false; const parsed = browserCleanupResultSchema.safeParse(message); if (!parsed.success) throw new Error("invalid browser lifecycle result"); const result = parsed.data; const completed = result.ok ? this.#finish(result.requestId) : false; const waiter = this.#waiters.get(result.requestId); if (waiter) { clearTimeout(waiter.timer); this.#waiters.delete(result.requestId); waiter.resolve(result.ok && completed); } return true; } #finish(requestId: string): boolean { const request = this.#pending.get(requestId); if (!request) return true; if (request.phase !== "committed") return false; this.#pending.delete(requestId); try { this.#save(); } catch (error) { // Keep the item when the acknowledgement could not be persisted, so a retry never treats stale credentials as erased. this.#pending.set(requestId, request); console.error("browser cleanup: could not persist acknowledgement", error); return false; } const retry = this.#retryTimers.get(requestId); if (retry) clearTimeout(retry); this.#retryTimers.delete(requestId); return true; } async #attempt(request: BrowserCleanupRequest): Promise { const current = this.#pending.get(request.requestId); if (!current) return true; if (current.phase !== "committed") return false; const result = new Promise((resolve) => { const prior = this.#waiters.get(request.requestId); if (prior) { clearTimeout(prior.timer); prior.resolve(false); } const timer = setTimeout(() => { if (this.#waiters.get(request.requestId)?.timer !== timer) return; this.#waiters.delete(request.requestId); resolve(false); }, this.#timeoutMs); timer.unref?.(); this.#waiters.set(request.requestId, { resolve, timer }); }); const sent = this.#send({ type: current.kind === "bot" ? "openmausbot:browser-bot-deleted" : "openmausbot:browser-profile-deleted", requestId: current.requestId, ...(current.kind === "bot" ? { botId: current.id } : { partitionId: current.partitionId }), }); if (!sent) { const waiter = this.#waiters.get(request.requestId); if (waiter) { clearTimeout(waiter.timer); this.#waiters.delete(request.requestId); waiter.resolve(false); } } return result; } async ensure(request: BrowserCleanupRequest): Promise { this.#assertHealthy(); const current = this.#pending.get(request.requestId); if (!current) return true; if (current.phase !== "committed") return false; const active = this.#inflight.get(request.requestId); if (active) return active; const operation = this.#attempt(current).finally(() => { if (this.#inflight.get(request.requestId) === operation) this.#inflight.delete(request.requestId); }); this.#inflight.set(request.requestId, operation); const ok = await operation; if (!ok && this.#pending.has(request.requestId)) this.#schedule(current, 0); return ok; } #schedule(request: BrowserCleanupRequest, attempt: number): void { if ( this.#retryTimers.has(request.requestId) || this.#pending.get(request.requestId)?.phase !== "committed" ) return; const delay = this.#retryMs[Math.min(attempt, this.#retryMs.length - 1)]!; const timer = setTimeout(() => { this.#retryTimers.delete(request.requestId); void this.#attempt(request).then((ok) => { if (!ok && this.#pending.has(request.requestId)) this.#schedule(request, attempt + 1); }); }, delay); timer.unref?.(); this.#retryTimers.set(request.requestId, timer); } startPending(): void { if (this.#loadFailure) { console.error(`browser cleanup: ${this.#loadFailure.message}`); return; } for (const request of this.#pending.values()) { if (request.phase === "committed") this.#schedule(request, 0); } } } /** Defaults for config.json's browserEngine.idleTimeoutMinutes (1-60); agent-browser's own backstop closes an idle daemon after an hour. */ export const DEFAULT_BROWSER_IDLE_MINUTES = 10; export const CLOUD_HOME_BROWSER_IDLE_MINUTES = 3; export function browserIdleMs(cfg: { browserEngine?: { idleTimeoutMinutes?: number } }, { cloudHome = false }: { cloudHome?: boolean } = {}): number { return (cfg.browserEngine?.idleTimeoutMinutes ?? (cloudHome ? CLOUD_HOME_BROWSER_IDLE_MINUTES : DEFAULT_BROWSER_IDLE_MINUTES)) * 60_000; } /** agent-browser writes one `.pid` per daemon, so with none there is nothing to ask about. */ function daemonsMayRun(env: NodeJS.ProcessEnv): boolean { try { return readdirSync(dirname(browserDaemonFile({ ...env, AGENT_BROWSER_SESSION: "" }, ".pid"))).some((name) => name.endsWith(".pid") && name !== "dashboard.pid"); } catch { return false; } } /** agent-browser detaches each daemon and its Chrome, so they outlive a crashed or killed server. A browser attached to the * person's own Chrome, or anything else under the same HOME, is never this data dir's to close. */ export class BrowserSweep { readonly #engine: () => string | null; readonly #owns: (session: string) => boolean; readonly #live: (session: string) => boolean; readonly #held: (session: string) => boolean; readonly #closeIdle: (session: string) => Promise; readonly #intervalMs: number; readonly #warned = new Set(); #timer: ReturnType | undefined; #pass: Promise | undefined; #hadDaemons = false; constructor(options: { engine: () => string | null; owns: (session: string) => boolean; live: (session: string) => boolean; /** A turn holds it until it finishes: never closed while one does. */ held: (session: string) => boolean; closeIdle: (session: string) => Promise; intervalMs?: number; }) { this.#engine = options.engine; this.#owns = options.owns; this.#live = options.live; this.#held = options.held; this.#closeIdle = options.closeIdle; this.#intervalMs = options.intervalMs ?? 60_000; } start(): this { void this.sweep(true); this.#timer = setInterval(() => void this.sweep(), this.#intervalMs); this.#timer.unref?.(); return this; } sweep(boot = false): Promise { this.#pass ??= this.#sweep(boot).catch((error) => console.warn("browser sweep:", error)).finally(() => { this.#pass = undefined; }); return this.#pass; } async #owned(binaryPath: string, timeoutMs?: number): Promise { const env = browserRuntimeEnv({}); if (!daemonsMayRun(env)) return null; return (await runningBrowserSessionNames(binaryPath, env, timeoutMs))?.filter((session) => this.#owns(session)) ?? null; } async #sweep(boot: boolean): Promise { const binaryPath = this.#engine(); if (!binaryPath) return; const owned = await this.#owned(binaryPath); if (boot || owned || this.#hadDaemons) { for (const { session, tree } of await orphanedBrowsers(browserRuntimeEnv({}).HOME, browserProfilesRoot())) { if (!this.#owns(session)) continue; if (await stopBrowserProcesses(tree)) console.info(`browser ${session}: stopped a browser whose engine had been killed under it`); else console.warn(`browser ${session}: could not stop a browser whose engine had been killed under it`); } } this.#hadDaemons = owned !== null; for (const session of owned ?? []) { // Read per browser, after the awaits before it: a turn may have started using it. if (this.#held(session)) continue; if (this.#live(session)) { if (await this.#closeIdle(session)) console.info(`browser ${session}: closed after it sat unused`); continue; } const closed = await closeBrowserSession(binaryPath, browserRuntimeEnv({ AGENT_BROWSER_SESSION: session })); if (closed) { this.#warned.delete(session); console.info(`browser ${session}: closed a browser nothing here uses any more`); } else if (!this.#warned.has(session)) { this.#warned.add(session); console.warn(`browser ${session}: could not close a browser nothing here uses any more; the sweep keeps trying`); } } } async closeAll(timeoutMs = 4_000): Promise { clearInterval(this.#timer); this.#timer = undefined; const binaryPath = this.#engine(); const owned = binaryPath ? await this.#owned(binaryPath, timeoutMs).catch(() => null) : null; await Promise.all((owned ?? []).map(async (session) => { if (!await closeBrowserSession(binaryPath!, browserRuntimeEnv({ AGENT_BROWSER_SESSION: session }), timeoutMs)) { console.warn(`browser ${session}: could not close its browser at shutdown`); } })); } }