Files

861 lines
32 KiB
JavaScript

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<string, string | undefined>} */
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<import("@openwork/types/desktop-ipc").NukeReceipt>} */
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;
}