#!/usr/bin/env node // Runs after install to keep packaged dist safe and compatible. // Keep packaged dist safe and compatible. Plugin package dependencies are // installed only by explicit plugin install/update flows, never postinstall. import { existsSync, lstatSync, opendirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, unlinkSync, } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const DEFAULT_PACKAGE_ROOT = join(scriptDir, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json"; // One budget covers all three prune walks (legacy-deps prepass, file listing, // empty-dir sweep). npm upgrades transiently hold old+new content-hashed dist // files, so a real upgrade scan totals ~24k entries today (2026.6.x); keep ~4x // headroom so dist growth cannot fail `npm install -g` while still refusing // pathological/unbounded trees. export const MAX_INSTALLED_DIST_SCAN_ENTRIES = 100_000; const LEGACY_PLUGIN_RUNTIME_DEPS_DIR = "plugin-runtime-deps"; class InstalledDistScanLimitError extends Error {} function normalizeRelativePath(filePath) { return filePath.replace(/\\/g, "/"); } function resolvePostinstallOsHomeDir(env, getHomedir = homedir) { return env?.HOME?.trim() || env?.USERPROFILE?.trim() || getHomedir(); } function resolvePostinstallTildePath(input, homeDir) { if (input === "~") { return homeDir; } if (input.startsWith("~/") || input.startsWith("~\\")) { return join(homeDir, input.slice(2)); } return input; } function resolvePostinstallOpenClawHomeDir(env, getHomedir = homedir) { const osHome = resolvePostinstallOsHomeDir(env, getHomedir); const override = env?.OPENCLAW_HOME?.trim(); return override ? pathResolve(resolvePostinstallTildePath(override, osHome)) : osHome; } function resolvePostinstallUserPath(input, openClawHome) { return pathResolve(resolvePostinstallTildePath(input, openClawHome)); } function readInstalledDistInventory(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; const readFile = params.readFileSync ?? readFileSync; const inventoryPath = join(packageRoot, DIST_INVENTORY_PATH); if (!pathExists(inventoryPath)) { throw new Error(`missing dist inventory: ${DIST_INVENTORY_PATH}`); } let parsed; try { parsed = JSON.parse(readFile(inventoryPath, "utf8")); } catch { throw new Error(`invalid dist inventory: ${DIST_INVENTORY_PATH}`); } if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) { throw new Error(`invalid dist inventory: ${DIST_INVENTORY_PATH}`); } return new Set(parsed.map(normalizeRelativePath)); } function isRecoverableInstalledDistInventoryError(error) { return error instanceof Error && /^(missing|invalid) dist inventory: /u.test(error.message); } function resolveInstalledDistRoot(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; const pathLstat = params.lstatSync ?? lstatSync; const resolveRealPath = params.realpathSync ?? realpathSync; const distDir = join(packageRoot, "dist"); if (!pathExists(distDir)) { return null; } const distStats = pathLstat(distDir); if (!distStats.isDirectory() || distStats.isSymbolicLink()) { throw new Error("unsafe dist root: dist must be a real directory"); } const packageRootReal = resolveRealPath(packageRoot); const distDirReal = resolveRealPath(distDir); const relativeDistPath = relative(packageRootReal, distDirReal); if (relativeDistPath !== "dist") { throw new Error("unsafe dist root: dist escaped package root"); } return { distDir, distDirReal, packageRootReal }; } function assertSafeInstalledDistPath(relativePath, params) { const resolveRealPath = params.realpathSync ?? realpathSync; const candidatePath = join(params.packageRoot, relativePath); const candidateRealPath = resolveRealPath(candidatePath); const relativeCandidatePath = relative(params.distDirReal, candidateRealPath); if (relativeCandidatePath.startsWith("..") || isAbsolute(relativeCandidatePath)) { throw new Error(`unsafe dist path: ${relativePath}`); } return candidatePath; } function createInstalledDistScanBudget(params = {}) { return { entries: 0, limit: params.maxDistScanEntries ?? MAX_INSTALLED_DIST_SCAN_ENTRIES, }; } function resolveInstalledDistScanBudget(params = {}) { return params.distScanBudget ?? createInstalledDistScanBudget(params); } function countInstalledDistScanEntry(budget) { budget.entries += 1; if (budget.entries > budget.limit) { throw new InstalledDistScanLimitError( `installed dist scan exceeded ${budget.limit} filesystem entries; refusing to scan unbounded package contents`, ); } } function* iterateInstalledDistEntries(currentDir, params = {}) { if (params.readdirSync) { yield* params.readdirSync(currentDir, { withFileTypes: true }); return; } const dir = opendirSync(currentDir); try { while (true) { const entry = dir.readSync(); if (!entry) { break; } yield entry; } } finally { dir.closeSync(); } } function* iterateOptionalInstalledDistEntries(currentDir, params = {}) { try { yield* iterateInstalledDistEntries(currentDir, params); } catch (error) { if (error instanceof InstalledDistScanLimitError) { throw error; } } } function listInstalledDistFiles(params = {}) { const distRoot = resolveInstalledDistRoot(params); if (distRoot === null) { return []; } const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pending = [distRoot.distDir]; const files = []; const budget = resolveInstalledDistScanBudget(params); while (pending.length > 0) { const currentDir = pending.pop(); if (!currentDir) { continue; } for (const entry of iterateInstalledDistEntries(currentDir, params)) { countInstalledDistScanEntry(budget); const entryPath = join(currentDir, entry.name); if (entry.isSymbolicLink()) { throw new Error( `unsafe dist entry: ${normalizeRelativePath(relative(packageRoot, entryPath))}`, ); } if (entry.isDirectory()) { pending.push(entryPath); continue; } if (!entry.isFile()) { continue; } const relativePath = normalizeRelativePath(relative(packageRoot, entryPath)); if (relativePath === DIST_INVENTORY_PATH) { continue; } files.push(relativePath); } } return files.toSorted((left, right) => left.localeCompare(right)); } function pruneEmptyDistDirectories(params = {}) { const removeDirectory = params.rmdirSync ?? rmdirSync; const distRoot = resolveInstalledDistRoot(params); if (distRoot === null) { return; } const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathLstat = params.lstatSync ?? lstatSync; const budget = resolveInstalledDistScanBudget(params); function isDirectoryEmpty(currentDir) { for (const entry of iterateInstalledDistEntries(currentDir, params)) { void entry; countInstalledDistScanEntry(budget); return false; } return true; } function prune(currentDir) { const childDirs = []; for (const entry of iterateInstalledDistEntries(currentDir, params)) { countInstalledDistScanEntry(budget); if (entry.isSymbolicLink()) { throw new Error( `unsafe dist entry: ${normalizeRelativePath(relative(packageRoot, join(currentDir, entry.name)))}`, ); } if (!entry.isDirectory()) { continue; } childDirs.push(join(currentDir, entry.name)); } for (const childDir of childDirs) { prune(childDir); } if (currentDir === distRoot.distDir) { return; } const currentStats = pathLstat(currentDir); if (!currentStats.isDirectory() || currentStats.isSymbolicLink()) { throw new Error( `unsafe dist directory: ${normalizeRelativePath(relative(packageRoot, currentDir))}`, ); } if (isDirectoryEmpty(currentDir)) { removeDirectory( assertSafeInstalledDistPath(normalizeRelativePath(relative(packageRoot, currentDir)), { packageRoot, distDirReal: distRoot.distDirReal, realpathSync: params.realpathSync, }), ); } } prune(distRoot.distDir); } function isLegacyInstalledPluginDependencyDirName(name) { return name === "node_modules" || /^\.openclaw-install-stage(?:-[^/]+)?$/iu.test(name); } function pruneLegacyInstalledPluginDependencyDirs(params) { const removePath = params.rmSync ?? rmSync; const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const extensionsDir = join(packageRoot, "dist", "extensions"); const budget = resolveInstalledDistScanBudget(params); const removed = []; for (const pluginEntry of iterateOptionalInstalledDistEntries(extensionsDir, params)) { countInstalledDistScanEntry(budget); if (!pluginEntry.isDirectory() || pluginEntry.isSymbolicLink()) { continue; } const pluginDir = join(extensionsDir, pluginEntry.name); const dependencyDirNames = []; for (const childEntry of iterateOptionalInstalledDistEntries(pluginDir, params)) { countInstalledDistScanEntry(budget); if (!isLegacyInstalledPluginDependencyDirName(childEntry.name)) { continue; } dependencyDirNames.push(childEntry.name); } if (dependencyDirNames.length === 0) { continue; } const safePluginDir = assertSafeInstalledDistPath( normalizeRelativePath(relative(packageRoot, pluginDir)), { packageRoot, distDirReal: params.distDirReal, realpathSync: params.realpathSync, }, ); for (const dependencyDirName of dependencyDirNames) { const relativePath = normalizeRelativePath( relative(packageRoot, join(pluginDir, dependencyDirName)), ); removePath(join(safePluginDir, dependencyDirName), { recursive: true, force: true }); removed.push(relativePath); } } return removed; } function splitPostinstallPathList(value) { return value ? value .split(pathDelimiter) .map((entry) => entry.trim()) .filter(Boolean) : []; } const pathDelimiter = process.platform === "win32" ? ";" : ":"; export function collectLegacyPluginRuntimeDepsStateRoots(params = {}) { const env = params.env ?? process.env; const getHomedir = params.homedir ?? homedir; const openClawHome = resolvePostinstallOpenClawHomeDir(env, getHomedir); const stateRoots = []; const addStateRoot = (root) => { if (root) { stateRoots.push(join(root, LEGACY_PLUGIN_RUNTIME_DEPS_DIR)); } }; const stateOverride = env?.OPENCLAW_STATE_DIR?.trim(); if (stateOverride) { addStateRoot(resolvePostinstallUserPath(stateOverride, openClawHome)); } const configPath = env?.OPENCLAW_CONFIG_PATH?.trim(); if (configPath) { addStateRoot(dirname(resolvePostinstallUserPath(configPath, openClawHome))); } addStateRoot(join(openClawHome, ".openclaw")); addStateRoot(join(openClawHome, ".clawdbot")); for (const entry of splitPostinstallPathList(env?.STATE_DIRECTORY)) { addStateRoot(resolvePostinstallUserPath(entry, openClawHome)); } return [...new Set(stateRoots.map((root) => pathResolve(root)))].toSorted((left, right) => left.localeCompare(right), ); } function isPathInsideRoot(candidate, root) { const relativePath = relative(root, candidate); return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); } function collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const readDir = params.readdirSync ?? readdirSync; const pathLstat = params.lstatSync ?? lstatSync; const readLink = params.readlinkSync ?? readlinkSync; const pathExists = params.existsSync ?? existsSync; const containingNodeModules = dirname(packageRoot); if (basename(containingNodeModules) !== "node_modules") { return []; } const normalizedRoots = roots.map((root) => pathResolve(root)); const candidates = []; function addCandidate(linkPath) { let linkStat; try { linkStat = pathLstat(linkPath); } catch { return; } if (!linkStat.isSymbolicLink()) { return; } let target; try { target = readLink(linkPath); } catch { return; } if (!target.includes(LEGACY_PLUGIN_RUNTIME_DEPS_DIR)) { return; } const resolvedTarget = pathResolve(dirname(linkPath), target); const pointsIntoPrunedRoot = normalizedRoots.some((root) => isPathInsideRoot(resolvedTarget, root), ); if (pointsIntoPrunedRoot || !pathExists(resolvedTarget)) { candidates.push(linkPath); } } let entries; try { entries = readDir(containingNodeModules, { withFileTypes: true }); } catch { return []; } for (const entry of entries) { if (entry.isDirectory() && entry.name.startsWith("@")) { const scopeDir = join(containingNodeModules, entry.name); let scopeEntries; try { scopeEntries = readDir(scopeDir, { withFileTypes: true }); } catch { continue; } for (const scopeEntry of scopeEntries) { addCandidate(join(scopeDir, scopeEntry.name)); } continue; } if (entry.isSymbolicLink()) { addCandidate(join(containingNodeModules, entry.name)); } } return [...new Set(candidates.map((entry) => pathResolve(entry)))].toSorted((left, right) => left.localeCompare(right), ); } export function pruneLegacyPluginRuntimeDepsState(params = {}) { const pathExists = params.existsSync ?? existsSync; const removePath = params.rmSync ?? rmSync; const unlinkPath = params.unlinkSync ?? unlinkSync; const log = params.log ?? console; const removed = []; const removedSymlinks = []; const roots = collectLegacyPluginRuntimeDepsStateRoots(params); for (const linkPath of collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params)) { try { unlinkPath(linkPath); removedSymlinks.push(linkPath); } catch (error) { log.warn?.( `[postinstall] could not prune legacy plugin runtime deps symlink ${linkPath}: ${String(error)}`, ); } } for (const root of roots) { if (!pathExists(root)) { continue; } try { removePath(root, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 }); removed.push(root); } catch (error) { log.warn?.( `[postinstall] could not prune legacy plugin runtime deps ${root}: ${String(error)}`, ); } } if (removed.length > 0) { log.log?.(`[postinstall] pruned legacy plugin runtime deps: ${removed.join(", ")}`); } if (removedSymlinks.length > 0) { log.log?.( `[postinstall] pruned legacy plugin runtime deps symlinks: ${removedSymlinks.join(", ")}`, ); } return removed; } export function pruneInstalledPackageDist(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const removeFile = params.unlinkSync ?? unlinkSync; const log = params.log ?? console; const distRoot = resolveInstalledDistRoot(params); if (distRoot === null) { return []; } const distScanBudget = createInstalledDistScanBudget(params); const distScanParams = { ...params, distScanBudget }; const removedLegacyDependencyDirs = pruneLegacyInstalledPluginDependencyDirs({ ...distScanParams, packageRoot, distDirReal: distRoot.distDirReal, realpathSync: params.realpathSync, rmSync: params.rmSync, }); let expectedFiles = params.expectedFiles ?? null; if (expectedFiles === null) { try { expectedFiles = readInstalledDistInventory(params); } catch (error) { if (!isRecoverableInstalledDistInventoryError(error)) { throw error; } log.warn?.(`[postinstall] skipping dist prune: ${error.message}`); return []; } } const installedFiles = listInstalledDistFiles(distScanParams); const readFile = params.readFileSync ?? readFileSync; expectedFiles = new Set( expandPackageDistImportClosure({ files: installedFiles, seedFiles: [...expectedFiles], readText(relativePath) { try { return readFile(join(packageRoot, relativePath), "utf8"); } catch (error) { if (error?.code === "ENOENT") { return ""; } throw error; } }, }), ); const removed = []; for (const relativePath of installedFiles) { if (expectedFiles.has(relativePath)) { continue; } removeFile( assertSafeInstalledDistPath(relativePath, { packageRoot, distDirReal: distRoot.distDirReal, realpathSync: params.realpathSync, }), ); removed.push(relativePath); } pruneEmptyDistDirectories(distScanParams); if (removed.length > 0) { log.log(`[postinstall] pruned stale dist files: ${removed.join(", ")}`); } if (removedLegacyDependencyDirs.length > 0) { log.log( `[postinstall] pruned legacy plugin dependency dirs: ${removedLegacyDependencyDirs.join(", ")}`, ); } return removed; } function resolveDistModuleUrl(packageRoot, distPath) { return pathToFileURL(join(packageRoot, distPath)).href; } async function importInstalledDistModule(params, distPath) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; const modulePath = join(packageRoot, distPath); if (!pathExists(modulePath)) { return null; } const importModule = params.importModule ?? ((specifier) => import(specifier)); return await importModule(resolveDistModuleUrl(packageRoot, distPath)); } export async function runPluginRegistryPostinstallMigration(params = {}) { const log = params.log ?? console; const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const env = params.env ?? process.env; const pathExists = params.existsSync ?? existsSync; // Registry migration belongs to installed-package upgrades. Source checkouts // can contain stale dist from a different build and must not touch operator state. if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) { return { status: "skipped", reason: "source-checkout" }; } try { const migrationModule = await importInstalledDistModule( { ...params, existsSync: pathExists }, "dist/commands/doctor/shared/plugin-registry-migration.js", ); if (!migrationModule) { return { status: "skipped", reason: "missing-dist-entry" }; } if (typeof migrationModule.migratePluginRegistryForInstall !== "function") { return { status: "skipped", reason: "missing-dist-contract" }; } const result = await migrationModule.migratePluginRegistryForInstall({ env, packageRoot, }); if (result.migrated) { log.log( `[postinstall] migrated plugin registry: ${result.current.plugins.length} plugin(s) indexed`, ); } return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.warn(`[postinstall] could not migrate plugin registry: ${message}`); return { status: "failed", error: message }; } } export function isSourceCheckoutRoot(params) { const pathExists = params.existsSync ?? existsSync; const hasPostinstallInventory = pathExists(join(params.packageRoot, DIST_INVENTORY_PATH)); return ( (pathExists(join(params.packageRoot, ".git")) || (pathExists(join(params.packageRoot, "pnpm-workspace.yaml")) && !hasPostinstallInventory)) && pathExists(join(params.packageRoot, "src")) && pathExists(join(params.packageRoot, "extensions")) ); } export function pruneBundledPluginSourceNodeModules(params = {}) { const extensionsDir = params.extensionsDir ?? join(DEFAULT_PACKAGE_ROOT, "extensions"); const pathExists = params.existsSync ?? existsSync; const readDir = params.readdirSync ?? readdirSync; const removePath = params.rmSync ?? rmSync; if (!pathExists(extensionsDir)) { return; } for (const entry of readDir(extensionsDir, { withFileTypes: true })) { if (!entry.isDirectory() || entry.isSymbolicLink()) { continue; } const pluginDir = join(extensionsDir, entry.name); if (!pathExists(join(pluginDir, "package.json"))) { continue; } removePath(join(pluginDir, "node_modules"), { recursive: true, force: true }); } } export function runBundledPluginPostinstall(params = {}) { const env = params.env ?? process.env; const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; const log = params.log ?? console; if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) { return; } if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) { try { pruneBundledPluginSourceNodeModules({ extensionsDir: join(packageRoot, "extensions"), existsSync: pathExists, readdirSync: params.readdirSync, rmSync: params.rmSync, }); } catch (e) { log.warn(`[postinstall] could not prune bundled plugin source node_modules: ${String(e)}`); } return; } pruneLegacyPluginRuntimeDepsState({ env, packageRoot, existsSync: pathExists, lstatSync: params.lstatSync, readlinkSync: params.readlinkSync, rmSync: params.rmSync, unlinkSync: params.unlinkSync, log, homedir: params.homedir, }); pruneInstalledPackageDist({ packageRoot, existsSync: pathExists, readFileSync: params.readFileSync, readdirSync: params.readdirSync, rmSync: params.rmSync, log, }); } export function isDirectPostinstallInvocation(params = {}) { const entryPath = params.entryPath ?? process.argv[1]; if (!entryPath) { return false; } const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); const resolveRealPath = params.realpathSync ?? realpathSync; try { return resolveRealPath(entryPath) === resolveRealPath(modulePath); } catch { return pathToFileURL(entryPath).href === pathToFileURL(modulePath).href; } } if (isDirectPostinstallInvocation()) { runBundledPluginPostinstall(); await runPluginRegistryPostinstallMigration(); }