import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { desktopBootstrapPath as resolveDesktopBootstrapPath, globalOpencodeConfigDir, legacyDesktopBootstrapPath as resolveLegacyDesktopBootstrapPath, openworkEnvStorePath, openworkServerConfigPath as resolveOpenworkServerConfigPath, opencodeCacheDirs as resolveOpencodeCacheDirs, opencodeDataDirs as resolveOpencodeDataDirs, } from "@openwork/paths"; const BROWSER_SESSION_PARTITION = "persist:openwork-browser"; const NUKE_PARTITIONS = ["default", BROWSER_SESSION_PARTITION]; const PENDING_NUKE_FILENAME = ".nuke-pending.json"; const WINDOWS_RETRY_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); const OPENWORK_CONFIG_FILENAMES = [ "server.json", "runtime.sqlite", "runtime.sqlite-wal", "runtime.sqlite-shm", "runtime-opencode-config.json", "engine-instances.json", "tokens.json", "env.json", "connect-state.json", "legacy-sweep-state.json", ]; const USERDATA_WORKSPACE_FILENAMES = [ "openwork-workspaces.json", "workspace-state.json", "openwork-server-tokens.json", "openwork-server-state.json", ]; const LEGACY_ORCHESTRATOR_DIR_NAME = ["openwork", "orchestrator"].join("-"); const SHIP_IT_CACHE_DOMAIN = "com.differentai.openwork.ShipIt"; const NUKE_WORKER_FILENAME = "nuke-worker.mjs"; const NUKE_WORKER_DEADLINE_MS = 60_000; const NUKE_WORKER_PARENT_WAIT_MS = 30_000; // Env overrides that move a profile's storage off the default locations. // Stripping them yields the paths the default (production) profile owns. const PROFILE_SCOPED_ENV_KEYS = [ "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENWORK_DATA_DIR", "OPENWORK_DESKTOP_BOOTSTRAP_PATH", "OPENWORK_DEV_MODE", "OPENWORK_ELECTRON_APP_IDENTIFIER", "OPENWORK_ELECTRON_USERDATA", "OPENWORK_ENV_STORE", "OPENWORK_RUNTIME_DB", "OPENWORK_SERVER_CONFIG", "OPENWORK_TOKEN_STORE", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", ]; const NUKE_WORKER_ENV_KEYS = [ "APPDATA", "LOCALAPPDATA", "OPENWORK_DATA_DIR", "OPENWORK_DESKTOP_BOOTSTRAP_PATH", "OPENWORK_DEV_MODE", "OPENWORK_ELECTRON_USERDATA", "OPENWORK_ENV_STORE", "OPENWORK_RUNTIME_DB", "OPENWORK_SERVER_CONFIG", "OPENWORK_TOKEN_STORE", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", ]; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function pathApi(platform) { return platform === "win32" ? path.win32 : path.posix; } function envValue(env, key) { return String(env?.[key] ?? "").trim(); } function isTruthyDevMode(env) { return envValue(env, "OPENWORK_DEV_MODE") === "1"; } // A profile is "isolated" when it was launched with its own storage identity: // dev mode, an explicit userData directory, or a non-default app identifier. // Nuking such a profile must never reach into the default (production) profile. function isIsolatedProfile(env) { return ( isTruthyDevMode(env) || envValue(env, "OPENWORK_ELECTRON_APP_IDENTIFIER") !== "" || envValue(env, "OPENWORK_ELECTRON_USERDATA") !== "" ); } function normalizePlatform(value) { return value === "win32" || value === "darwin" || value === "linux" ? value : "linux"; } function resolveNukeEnvironment({ env = {}, homedir, platform, userDataPath }) { const normalizedPlatform = normalizePlatform(platform); const paths = pathApi(normalizedPlatform); /** @type {Record} */ const resolvedEnv = { ...env }; let resolvedHome = homedir; const userDataOverride = envValue(env, "OPENWORK_ELECTRON_USERDATA"); const resolvedUserDataPath = userDataOverride || userDataPath; if (isTruthyDevMode(env)) { const root = paths.join(resolvedUserDataPath, "openwork-dev-data"); resolvedHome = paths.join(root, "home"); resolvedEnv.HOME = resolvedHome; resolvedEnv.USERPROFILE = resolvedHome; resolvedEnv.XDG_CONFIG_HOME = paths.join(root, "xdg", "config"); resolvedEnv.XDG_DATA_HOME = paths.join(root, "xdg", "data"); resolvedEnv.XDG_CACHE_HOME = paths.join(root, "xdg", "cache"); resolvedEnv.XDG_STATE_HOME = paths.join(root, "xdg", "state"); resolvedEnv.OPENCODE_CONFIG_DIR = paths.join(root, "config", "opencode"); resolvedEnv.OPENCODE_TEST_HOME = resolvedHome; } return { env: resolvedEnv, homedir: resolvedHome, platform: normalizedPlatform, paths, userDataPath: resolvedUserDataPath, }; } function desktopConfigHome(env, homedir, platform, paths) { const xdgConfigHome = envValue(env, "XDG_CONFIG_HOME"); if (xdgConfigHome) return xdgConfigHome; if (platform === "win32") { const localAppData = envValue(env, "LOCALAPPDATA"); if (localAppData) return localAppData; return paths.join(homedir, "AppData", "Local"); } return paths.join(homedir, ".config"); } function openworkServerConfigPath(env, homedir, platform, paths) { return resolveOpenworkServerConfigPath({ env, homeDir: homedir, platform }); } function envStorePath(env, homedir, platform, paths) { return openworkEnvStorePath({ env, homeDir: homedir, platform }); } function tokenStorePath(env, serverConfigPath, homedir, paths) { const override = envValue(env, "OPENWORK_TOKEN_STORE"); if (override) return paths.resolve(override); const configDir = serverConfigPath ? paths.dirname(serverConfigPath) : paths.join(homedir, ".config", "openwork"); return paths.join(configDir, "tokens.json"); } function runtimeDbPath(env, serverConfigPath, homedir, paths) { const override = envValue(env, "OPENWORK_RUNTIME_DB"); if (override) return paths.resolve(override); const configDir = serverConfigPath ? paths.dirname(serverConfigPath) : paths.join(homedir, ".config", "openwork"); return paths.join(configDir, "runtime.sqlite"); } function desktopBootstrapPath(env, homedir, platform, paths, userDataPath) { return resolveDesktopBootstrapPath({ env, homeDir: homedir, platform, userDataDir: userDataPath }); } function legacyDesktopBootstrapPath(homedir, platform) { return resolveLegacyDesktopBootstrapPath({ env: {}, homeDir: homedir, platform }); } function opencodeDataDirs(env, homedir, platform, paths) { return resolveOpencodeDataDirs({ env, homeDir: homedir, platform }); } function opencodeConfigDirs(env, homedir, platform, paths) { return [globalOpencodeConfigDir({ env, homeDir: homedir, platform })]; } function opencodeCacheDirs(env, homedir, platform) { return resolveOpencodeCacheDirs({ env, homeDir: homedir, platform }); } function opencodeStateDirs(env, homedir, platform, paths) { const dirs = []; const xdgStateHome = envValue(env, "XDG_STATE_HOME"); if (xdgStateHome) dirs.push(paths.join(xdgStateHome, "opencode")); dirs.push(paths.join(homedir, ".local", "state", "opencode")); if (platform === "win32") { const localAppData = envValue(env, "LOCALAPPDATA"); dirs.push(paths.join(localAppData || paths.join(homedir, "AppData", "Local"), "opencode")); } return dirs; } function orchestratorDataDir(env, homedir, paths) { const override = envValue(env, "OPENWORK_DATA_DIR"); if (override) return override; return paths.join(homedir, ".openwork", LEGACY_ORCHESTRATOR_DIR_NAME); } function serverDataDir(env, homedir, paths) { const override = envValue(env, "OPENWORK_DATA_DIR"); if (override) return override; return paths.join(homedir, ".openwork", "openwork-server"); } /** Workspace-local state OpenWork owns; the rest of the workspace folder is the user's. */ function workspaceOpenworkStatePaths(workspacePaths, paths) { const output = []; for (const workspacePath of workspacePaths) { const value = String(workspacePath ?? "").trim(); if (!value) continue; const opencodeDir = paths.join(paths.resolve(value), ".opencode"); output.push(paths.join(opencodeDir, "openwork"), paths.join(opencodeDir, "openwork.json")); } return output; } function opencodeDbOverridePaths(env, dataDirs, paths) { const override = envValue(env, "OPENCODE_DB"); if (!override) return []; if (paths.isAbsolute(override)) return [override]; return dataDirs.map((dir) => paths.join(dir, override)); } function sameOrInside(candidate, parent, paths, platform) { if (!candidate || !parent) return false; const candidateResolved = paths.resolve(candidate); const parentResolved = paths.resolve(parent); const keyCandidate = platform === "win32" ? candidateResolved.toLowerCase() : candidateResolved; const keyParent = platform === "win32" ? parentResolved.toLowerCase() : parentResolved; const relative = paths.relative(keyParent, keyCandidate); return relative === "" || (!!relative && !relative.startsWith("..") && !paths.isAbsolute(relative)); } function shouldSkipDeletePath(targetPath, preserveBootstrapPath, homedir, paths, platform) { if (!targetPath) return true; if (preserveBootstrapPath && paths.resolve(targetPath) === paths.resolve(preserveBootstrapPath)) return true; const opencodeBin = paths.join(homedir, ".opencode", "bin"); return sameOrInside(targetPath, opencodeBin, paths, platform) || paths.resolve(targetPath) === paths.dirname(paths.resolve(opencodeBin)); } function uniquePaths(rawPaths, paths, platform) { const seen = new Set(); const output = []; for (const rawPath of rawPaths) { const value = String(rawPath ?? "").trim(); if (!value) continue; const normalized = paths.normalize(value); const key = platform === "win32" ? normalized.toLowerCase() : normalized; if (seen.has(key)) continue; seen.add(key); output.push(normalized); } return output; } // Paths the default profile owns. Resolved by replanning with every // profile-shaping env override stripped, so the two plans can never disagree // about which locations are shared with production. function defaultProfileDeletePaths(input) { const env = { ...input.env }; for (const key of PROFILE_SCOPED_ENV_KEYS) delete env[key]; return resolveNukePlan({ ...input, env, scopeToProfile: false }).manifest.deletePaths; } function profileScopedDeletePaths(deletePaths, sharedPaths, profileRoot, paths, platform) { return deletePaths.filter((targetPath) => { if (sameOrInside(targetPath, profileRoot, paths, platform)) return true; return !sharedPaths.some( (sharedPath) => sameOrInside(targetPath, sharedPath, paths, platform) || sameOrInside(sharedPath, targetPath, paths, platform), ); }); } function addOpenworkConfigFiles(deletePaths, roots, paths) { for (const root of roots) { if (!root) continue; for (const filename of OPENWORK_CONFIG_FILENAMES) { deletePaths.push(paths.join(root, filename)); } } } function resolveNukePlan(input) { const resolved = resolveNukeEnvironment(input); const { env, homedir, platform, paths, userDataPath } = resolved; const bootstrapPath = desktopBootstrapPath(env, homedir, platform, paths, userDataPath); const preserveBootstrapPath = input.preserveBootstrap === false ? null : bootstrapPath; const legacyBootstrapPath = legacyDesktopBootstrapPath(homedir, platform); const serverConfig = openworkServerConfigPath(env, homedir, platform, paths); const runtimeDb = runtimeDbPath(env, serverConfig, homedir, paths); const envStore = envStorePath(env, homedir, platform, paths); const tokens = tokenStorePath(env, serverConfig, homedir, paths); const runtimeConfig = paths.join(paths.dirname(runtimeDb), "runtime-opencode-config.json"); const dataDirs = opencodeDataDirs(env, homedir, platform, paths); const deletePaths = [ userDataPath, serverConfig, runtimeDb, `${runtimeDb}-wal`, `${runtimeDb}-shm`, runtimeConfig, tokens, envStore, bootstrapPath, legacyBootstrapPath, ...dataDirs, ...opencodeDbOverridePaths(env, dataDirs, paths), ...opencodeConfigDirs(env, homedir, platform, paths), ...opencodeCacheDirs(env, homedir, platform), ...opencodeStateDirs(env, homedir, platform, paths), orchestratorDataDir(env, homedir, paths), serverDataDir(env, homedir, paths), ...USERDATA_WORKSPACE_FILENAMES.map((filename) => paths.join(userDataPath, filename)), ...workspaceOpenworkStatePaths( Array.isArray(input.workspacePaths) ? input.workspacePaths : [], paths, ), ]; const openworkConfigRoots = [ paths.join(desktopConfigHome(env, homedir, platform, paths), "openwork"), paths.dirname(serverConfig), paths.dirname(runtimeDb), paths.dirname(tokens), paths.dirname(envStore), ]; deletePaths.push(...openworkConfigRoots); addOpenworkConfigFiles(deletePaths, openworkConfigRoots, paths); if (platform === "darwin") { deletePaths.push(paths.join(homedir, "Library", "Caches", SHIP_IT_CACHE_DOMAIN)); } const filteredDeletePaths = deletePaths.filter( (targetPath) => !shouldSkipDeletePath(targetPath, preserveBootstrapPath, homedir, paths, platform), ); const scopeToProfile = input.scopeToProfile !== false && isIsolatedProfile(input.env ?? {}); const sharedPaths = scopeToProfile ? defaultProfileDeletePaths(input) : []; const scopeDeletePaths = (candidates) => scopeToProfile ? profileScopedDeletePaths(candidates, sharedPaths, userDataPath, paths, platform) : candidates; const manifest = { deletePaths: uniquePaths(scopeDeletePaths(filteredDeletePaths), paths, platform), bootstrapPath, preserveBootstrapPath: preserveBootstrapPath || null, partitions: [...NUKE_PARTITIONS], }; // Isolated profiles keep the sentinel in their own userData so another // profile's next launch can never pick up and replay their cleanup. const pendingPath = scopeToProfile ? paths.join(userDataPath, PENDING_NUKE_FILENAME) : paths.join(desktopConfigHome(env, homedir, platform, paths), "openwork", PENDING_NUKE_FILENAME); return { manifest, pendingPath, scopeToProfile, scopeDeletePaths, preservePaths: uniquePaths([preserveBootstrapPath, paths.join(homedir, ".opencode", "bin")], paths, platform), legacyBootstrapPath: paths.resolve(legacyBootstrapPath) === paths.resolve(bootstrapPath) ? null : legacyBootstrapPath, platform, }; } export function buildNukeManifest(input) { return resolveNukePlan(input).manifest; } export function buildNukeWorkerNukeInput(input) { const env = {}; for (const key of NUKE_WORKER_ENV_KEYS) { const value = envValue(input.env, key); if (value) env[key] = value; } return { env, homedir: String(input.homedir ?? ""), platform: normalizePlatform(input.platform), preserveBootstrap: input.preserveBootstrap !== false, userDataPath: String(input.userDataPath ?? ""), workspacePaths: (Array.isArray(input.workspacePaths) ? input.workspacePaths : []) .map((entry) => String(entry ?? "").trim()) .filter(Boolean), }; } function safeNukeWorkerAppArgv(argv) { const input = Array.isArray(argv) ? argv : []; const entrypoint = typeof input[0] === "string" ? input[0].trim() : ""; return entrypoint.endsWith("main.mjs") ? [entrypoint] : []; } export function buildNukeWorkerPayload({ parentPid, nukeInput, appExecutablePath, appArgv, pendingPath, nowMs = Date.now() }) { return { version: 1, parentPid: Number(parentPid) || 0, nukeInput: buildNukeWorkerNukeInput(nukeInput), appExecutablePath: String(appExecutablePath ?? ""), appArgv: safeNukeWorkerAppArgv(appArgv), pendingPath: String(pendingPath ?? ""), parentWaitDeadlineAt: nowMs + NUKE_WORKER_PARENT_WAIT_MS, deadlineAt: nowMs + NUKE_WORKER_DEADLINE_MS, }; } function nukeWorkerScriptPath() { return path.join(__dirname, NUKE_WORKER_FILENAME); } function nukeWorkerPayloadPath() { return path.join(os.tmpdir(), `openwork-nuke-worker-${Date.now()}-${randomBytes(6).toString("hex")}.json`); } async function writeNukeWorkerPayload(payloadPath, payload) { await writeFile(payloadPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); } function nukeWorkerSpawnEnv(env = process.env) { const workerEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" }; delete workerEnv["OPENWORK_ELECTRON_REMOTE_DEBUG_PORT"]; return workerEnv; } function nukeRelaunchArgv(app, argv) { const input = Array.isArray(argv) ? argv.slice(1) : []; const output = []; if (app?.isPackaged !== true && input[0]?.endsWith("main.mjs")) output.push(input.shift()); output.push(...safeNukeWorkerAppArgv(input)); return output; } export async function scheduleNukeCleanupWorker({ app, input, plan, spawnFn, execPath = process.execPath, argv = process.argv, env = process.env }) { const payloadPath = nukeWorkerPayloadPath(); const payload = buildNukeWorkerPayload({ parentPid: process.pid, nukeInput: input, appExecutablePath: execPath, appArgv: nukeRelaunchArgv(app, argv), pendingPath: plan.pendingPath, }); await writeNukeWorkerPayload(payloadPath, payload); try { const launchWorker = typeof spawnFn === "function" ? spawnFn : spawn; const child = launchWorker(execPath, [nukeWorkerScriptPath(), payloadPath], { detached: true, env: nukeWorkerSpawnEnv(env), stdio: "ignore", windowsHide: true, }); child.unref(); return { pid: child.pid ?? null, payloadPath }; } catch (error) { await rm(payloadPath, { force: true }).catch(() => undefined); throw error; } } export function sanitizeDesktopBootstrapConfig(input, writtenAt = new Date().toISOString()) { if (!input || typeof input !== "object" || Array.isArray(input)) return null; const baseUrl = typeof input.baseUrl === "string" ? input.baseUrl.trim() : ""; if (!baseUrl) return null; const apiBaseUrl = typeof input.apiBaseUrl === "string" ? input.apiBaseUrl.trim() : ""; const brandAppName = typeof input.brandAppName === "string" ? input.brandAppName.trim().slice(0, 64) : ""; const brandLogoUrl = typeof input.brandLogoUrl === "string" ? input.brandLogoUrl.trim() : ""; const brandIconUrl = typeof input.brandIconUrl === "string" ? input.brandIconUrl.trim() : ""; return { baseUrl, ...(apiBaseUrl ? { apiBaseUrl } : {}), requireSignin: input.requireSignin === true, ...(brandAppName ? { brandAppName } : {}), ...(brandLogoUrl ? { brandLogoUrl } : {}), ...(brandIconUrl ? { brandIconUrl } : {}), writtenAt, }; } async function readJsonIfPresent(targetPath) { try { const raw = await readFile(targetPath, "utf8"); const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; return { exists: true, ok: true, value: JSON.parse(content) }; } catch (error) { if (errorCode(error) === "ENOENT") return { exists: false, ok: false, value: null }; return { exists: true, ok: false, value: null }; } } async function writeJsonFile(targetPath, value) { await mkdir(path.dirname(targetPath), { recursive: true }); await writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } export async function sanitizeDesktopBootstrapFiles({ canonicalPath, legacyPath }) { const canonical = await readJsonIfPresent(canonicalPath); const legacy = legacyPath ? await readJsonIfPresent(legacyPath) : { exists: false, ok: false, value: null }; const canonicalSanitized = canonical.ok ? sanitizeDesktopBootstrapConfig(canonical.value) : null; const legacySanitized = legacy.ok ? sanitizeDesktopBootstrapConfig(legacy.value) : null; const sanitized = canonicalSanitized ?? legacySanitized; if (sanitized) { await writeJsonFile(canonicalPath, sanitized); if (legacyPath) await rm(legacyPath, { force: true }); return true; } if (canonical.exists) await rm(canonicalPath, { force: true }); if (legacyPath && legacy.exists) await rm(legacyPath, { force: true }); return false; } async function sanitizeDesktopBootstrapOnDisk(plan) { const preservePath = plan.manifest.preserveBootstrapPath; if (!preservePath) return false; return sanitizeDesktopBootstrapFiles({ canonicalPath: preservePath, legacyPath: plan.legacyBootstrapPath, }); } function errorCode(error) { if (!error || (typeof error !== "object" && typeof error !== "function")) return ""; return typeof error.code === "string" ? error.code : ""; } function errorMessage(error) { if (error instanceof Error) return error.message; return String(error); } function receiptError(targetPath, error) { const code = errorCode(error); return { path: targetPath, message: errorMessage(error), ...(code ? { code } : {}), }; } function isWindowsRetryable(error, platform) { return platform === "win32" && WINDOWS_RETRY_CODES.has(errorCode(error)); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function nativeSameOrInside(candidate, parent) { const candidateResolved = path.resolve(candidate); const parentResolved = path.resolve(parent); const relative = path.relative(parentResolved, candidateResolved); return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); } function containedPreservePaths(targetPath, preservePaths) { return preservePaths.filter((preservePath) => nativeSameOrInside(preservePath, targetPath)); } async function removeDirectoryContentsExcept(targetPath, preservePaths) { const entries = await readdir(targetPath, { withFileTypes: true }); for (const entry of entries) { const childPath = path.join(targetPath, entry.name); const childPreservePaths = containedPreservePaths(childPath, preservePaths); if (childPreservePaths.length > 0) { if (entry.isDirectory()) await removeDirectoryContentsExcept(childPath, childPreservePaths); continue; } await rm(childPath, { recursive: true, force: true }); } } async function removePathPreservingPaths(targetPath, preservePaths) { if (preservePaths.some((preservePath) => nativeSameOrInside(targetPath, preservePath))) return; const contained = containedPreservePaths(targetPath, preservePaths); if (contained.length === 0) { await rm(targetPath, { recursive: true, force: true }); return; } if (!existsSync(targetPath)) return; const info = await stat(targetPath); if (!info.isDirectory()) return; await removeDirectoryContentsExcept(targetPath, contained); } async function removePathWithRetry(targetPath, preservePaths, platform) { let lastError = null; for (let attempt = 0; attempt < 4; attempt += 1) { try { await removePathPreservingPaths(targetPath, preservePaths); return null; } catch (error) { lastError = error; if (!isWindowsRetryable(error, platform) || attempt === 3) break; await sleep(250); } } return lastError; } async function onlyPreserveBranchesRemain(targetPath, preservePaths) { const contained = containedPreservePaths(targetPath, preservePaths); if (contained.length === 0) return false; if (!existsSync(targetPath)) return true; const entries = await readdir(targetPath, { withFileTypes: true }); for (const entry of entries) { const childPath = path.join(targetPath, entry.name); const childPreservePaths = containedPreservePaths(childPath, contained); if (childPreservePaths.length === 0) return false; if (entry.isDirectory() && !(await onlyPreserveBranchesRemain(childPath, childPreservePaths))) return false; } return true; } async function pathDeletedOrPreservedOnly(targetPath, preservePaths) { try { await stat(targetPath); } catch { return true; } return onlyPreserveBranchesRemain(targetPath, preservePaths); } async function deleteManifestPaths(manifest, platform, preservePaths, options = {}) { const errors = []; const removePath = typeof options.removePathWithRetry === "function" ? options.removePathWithRetry : removePathWithRetry; for (const targetPath of manifest.deletePaths) { const error = await removePath(targetPath, preservePaths, platform); if (error) errors.push(receiptError(targetPath, error)); } return errors; } async function verifyManifestDeletion(manifest, deletionErrors, preservePaths) { const deleted = []; const pendingRetry = []; const errors = [...deletionErrors]; const erroredPaths = new Set(deletionErrors.map((error) => error.path)); for (const targetPath of manifest.deletePaths) { if (await pathDeletedOrPreservedOnly(targetPath, preservePaths)) { deleted.push(targetPath); continue; } pendingRetry.push(targetPath); if (!erroredPaths.has(targetPath)) { errors.push({ path: targetPath, message: "Path still exists after deletion" }); } } return { deleted, pendingRetry, errors }; } function nukeNowIso(options = {}) { return typeof options.nowIso === "string" && options.nowIso.trim() ? options.nowIso.trim() : new Date().toISOString(); } function pendingCreatedAt(pending, fallback) { const createdAt = typeof pending?.createdAt === "string" ? pending.createdAt.trim() : ""; return createdAt && Number.isFinite(Date.parse(createdAt)) ? createdAt : fallback; } function pendingCleanupResult({ ran, invalid = false, deleted = [], pendingRetry = [], errors = [] }) { return { ran, invalid, deleted, pendingRetry, errors }; } async function writePendingNukeFile(pendingPath, paths, pending = null, options = {}) { if (paths.length === 0) return; const attemptedAt = nukeNowIso(options); await writeJsonFile(pendingPath, { paths, preserveBootstrap: typeof pending?.preserveBootstrap === "boolean" ? pending.preserveBootstrap : options.preserveBootstrap !== false, createdAt: pendingCreatedAt(pending, attemptedAt), attemptedAt, }); } async function writeReceipt(receipt) { const receiptPath = path.join(os.tmpdir(), `openwork-nuke-receipt-${Date.now()}.json`); await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); } async function clearSessionStorage(targetSession) { await targetSession.clearStorageData(); try { targetSession.flushStorageData(); } catch { // Best effort only; clearStorageData is the authoritative operation. } } async function clearChromiumStorage(sessionModule) { const browserSession = sessionModule.fromPartition(BROWSER_SESSION_PARTITION); await clearSessionStorage(sessionModule.defaultSession); await clearSessionStorage(browserSession); } function withTimeout(promise, timeoutMs, label) { let timer = null; return Promise.race([ promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); }), ]).finally(() => { if (timer) clearTimeout(timer); }); } async function bestEffort(errors, label, task, timeoutMs) { try { await withTimeout(Promise.resolve().then(task), timeoutMs, label); } catch (error) { errors.push(receiptError(label, error)); } } async function quiesceForNuke({ runtimeManager, uiControlServer, removeWindowsBrandShortcut }, errors, options = {}) { await bestEffort(errors, "ui-control-server", () => uiControlServer.stop(), 3000); await bestEffort(errors, "runtime-dispose", () => runtimeManager.dispose(), 12_000); await bestEffort(errors, "packaged-sidecar-reaper", () => runtimeManager.prepareFreshRuntime(), 16_000); // Container cleanup matches on name prefix across the whole Docker host, so it // cannot tell this profile's containers from another profile's. Only the // default profile may run it; isolated profiles leave containers alone rather // than force-removing production's. if (!options.scopeToProfile) { await bestEffort(errors, "sandbox-docker-cleanup", () => runtimeManager.sandboxCleanupOpenworkContainers(), 24_000); } await bestEffort(errors, "windows-brand-shortcut", removeWindowsBrandShortcut, 5000); } /** @returns {"cleanup_worker" | "direct"} */ function nukeReceiptRelaunchMode(workerScheduled) { return workerScheduled ? "cleanup_worker" : "direct"; } function preservePathsWithoutBootstrap(plan) { const bootstrapPath = plan.manifest.preserveBootstrapPath; if (!bootstrapPath) return plan.preservePaths; const resolvedBootstrapPath = path.resolve(bootstrapPath); return plan.preservePaths.filter((preservePath) => path.resolve(preservePath) !== resolvedBootstrapPath); } export async function runPendingNukeCleanup(input, options = {}) { const initialPlan = resolveNukePlan(input); const pendingFile = await readJsonIfPresent(initialPlan.pendingPath); if (!pendingFile.exists) return pendingCleanupResult({ ran: false }); if (!pendingFile.ok) { await rm(initialPlan.pendingPath, { force: true }); return pendingCleanupResult({ ran: false, invalid: true }); } const pending = pendingFile.ok ? pendingFile.value : null; const plan = resolveNukePlan({ ...input, preserveBootstrap: pending?.preserveBootstrap !== false }); // Re-scope on replay: a sentinel written by a different profile (or an older // build) must not be able to reach outside this profile's own storage. const paths = plan.scopeDeletePaths( Array.isArray(pending?.paths) ? pending.paths.filter((entry) => typeof entry === "string" && entry.trim()).map((entry) => entry.trim()) : [], ); if (paths.length === 0) { await rm(plan.pendingPath, { force: true }); return pendingCleanupResult({ ran: false }); } const manifest = { ...plan.manifest, deletePaths: paths }; const deletionErrors = await deleteManifestPaths(manifest, plan.platform, plan.preservePaths, options); const verified = await verifyManifestDeletion(manifest, deletionErrors, [...plan.preservePaths, plan.pendingPath]); if (verified.pendingRetry.length > 0) { await writePendingNukeFile(plan.pendingPath, verified.pendingRetry, pending, options); return pendingCleanupResult({ ran: true, ...verified }); } await rm(plan.pendingPath, { force: true }); return pendingCleanupResult({ ran: true, ...verified }); } /** @returns {Promise} */ export async function executeNukeFreshStart({ app, session, runtimeManager, uiControlServer, removeWindowsBrandShortcut }, options = {}) { const input = { ...(options.input ?? { env: process.env, homedir: os.homedir(), platform: process.platform, userDataPath: app.getPath("userData"), }), preserveBootstrap: options.preserveBootstrap ?? options.input?.preserveBootstrap ?? true, }; const plan = resolveNukePlan(input); const phaseErrors = []; await quiesceForNuke({ runtimeManager, uiControlServer, removeWindowsBrandShortcut }, phaseErrors, { scopeToProfile: plan.scopeToProfile, }); await bestEffort(phaseErrors, "chromium-storage", () => clearChromiumStorage(session), 8000); const preservedBootstrap = await sanitizeDesktopBootstrapOnDisk(plan).catch((error) => { phaseErrors.push(receiptError(plan.manifest.preserveBootstrapPath ?? "desktop-bootstrap", error)); return false; }); const preservePaths = preservedBootstrap ? plan.preservePaths : preservePathsWithoutBootstrap(plan); const deletionErrors = await deleteManifestPaths(plan.manifest, plan.platform, preservePaths, options); const verified = await verifyManifestDeletion(plan.manifest, [...phaseErrors, ...deletionErrors], preservePaths); if (verified.pendingRetry.length > 0) { await writePendingNukeFile(plan.pendingPath, verified.pendingRetry, null, { preserveBootstrap: input.preserveBootstrap, }).catch((error) => { verified.errors.push(receiptError(plan.pendingPath, error)); }); } const scheduleCleanupWorker = typeof options.scheduleCleanupWorker === "function" ? options.scheduleCleanupWorker : scheduleNukeCleanupWorker; let workerScheduled = false; if (verified.pendingRetry.length > 0) { try { await scheduleCleanupWorker({ app, input, plan }); workerScheduled = true; } catch (error) { verified.errors.push(receiptError("nuke-cleanup-worker", error)); } } const receipt = { deleted: verified.deleted, pendingRetry: verified.pendingRetry, errors: verified.errors, preservedBootstrap, relaunchMode: nukeReceiptRelaunchMode(workerScheduled), workerScheduled, }; await writeReceipt(receipt).catch((error) => { receipt.errors.push(receiptError("nuke-receipt", error)); }); setTimeout(() => { if (!workerScheduled) app.relaunch(); app.quit(); }, 100); return receipt; }