import { existsSync as existsSyncOriginal, readFileSync as readFileSyncOriginal } from "node:fs"; import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { collectLegacyPluginRuntimeDepsStateRoots, isSourceCheckoutRoot, isDirectPostinstallInvocation, pruneOpenClawCompileCache, pruneInstalledPackageDist, pruneLegacyPluginRuntimeDepsState, pruneBundledPluginSourceNodeModules, runBundledPluginPostinstall, runPluginRegistryPostinstallMigration, } from "../../scripts/postinstall-bundled-plugins.mjs"; import { writePackageDistInventory } from "../../src/infra/package-dist-inventory.ts"; import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDirAsync } = createScriptTestHarness(); async function createExtensionsDir() { const root = await createTempDirAsync("openclaw-postinstall-"); const extensionsDir = path.join(root, "dist", "extensions"); await fs.mkdir(extensionsDir, { recursive: true }); return extensionsDir; } async function writePluginPackage( extensionsDir: string, pluginId: string, packageJson: Record, ) { const pluginDir = path.join(extensionsDir, pluginId); await fs.mkdir(pluginDir, { recursive: true }); await fs.writeFile( path.join(pluginDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, ); const packageRoot = path.basename(path.dirname(extensionsDir)) === "dist" ? path.dirname(path.dirname(extensionsDir)) : path.dirname(extensionsDir); try { await writePackageDistInventory(packageRoot); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } } describe("bundled plugin postinstall", () => { function existsSyncWithoutGlobalCompileCache(value: string) { if (path.resolve(value) === path.join(tmpdir(), "node-compile-cache")) { return false; } return existsSyncOriginal(value); } it("recognizes direct invocation through symlinked temp prefixes", () => { const realpathSync = vi.fn((value: string) => value.replace(/^\/var\/folders\//u, "/private/var/folders/"), ); expect( isDirectPostinstallInvocation({ entryPath: "/var/folders/tmp/openclaw/scripts/postinstall-bundled-plugins.mjs", modulePath: "/private/var/folders/tmp/openclaw/scripts/postinstall-bundled-plugins.mjs", realpathSync, }), ).toBe(true); }); it("prunes Node versioned compile cache dirs during package postinstall", () => { const configuredBase = path.join("/tmp", "openclaw-cache"); const defaultBase = path.join(tmpdir(), "node-compile-cache"); const removed: string[] = []; const existsSync = vi.fn((value: string) => value === configuredBase || value === defaultBase); const readdirSync = vi.fn((value: string) => { if (value === configuredBase) { return [ { name: "v22.13.1-x64-efe9a9df-1001", isDirectory: () => true }, { name: "openclaw", isDirectory: () => true }, { name: "README", isDirectory: () => false }, ]; } if (value === defaultBase) { return [{ name: "v24.14.1-x64-efe9a9df-1001", isDirectory: () => true }]; } throw new Error(`unexpected readdir: ${value}`); }); const rmSync = vi.fn((value: string) => { removed.push(value); }); pruneOpenClawCompileCache({ env: { NODE_COMPILE_CACHE: configuredBase }, existsSync, readdirSync, rmSync, log: { warn: vi.fn() }, }); expect(removed).toEqual([ path.join(configuredBase, "v22.13.1-x64-efe9a9df-1001"), path.join(defaultBase, "v24.14.1-x64-efe9a9df-1001"), ]); expect(removed).not.toContain(path.join(configuredBase, "openclaw")); for (const cacheDir of removed) { expect(rmSync).toHaveBeenCalledWith(cacheDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 100, }); } }); it("keeps pruning sibling compile cache dirs after one removal fails", () => { const configuredBase = path.join("/tmp", "openclaw-cache"); const attempted: string[] = []; const warn = vi.fn(); const firstCacheDir = path.join(configuredBase, "v22.13.1-x64-efe9a9df-1001"); const secondCacheDir = path.join(configuredBase, "v22.13.1-x64-efe9a9df-1002"); const rmSync = vi.fn((value: string) => { attempted.push(value); if (value === firstCacheDir) { throw new Error("locked"); } }); pruneOpenClawCompileCache({ env: { NODE_COMPILE_CACHE: configuredBase }, existsSync: vi.fn((value: string) => value === configuredBase), readdirSync: vi.fn(() => [ { name: path.basename(firstCacheDir), isDirectory: () => true }, { name: path.basename(secondCacheDir), isDirectory: () => true }, ]), rmSync, log: { warn }, }); expect(attempted).toEqual([firstCacheDir, secondCacheDir]); expect(warn).toHaveBeenCalledWith( "[postinstall] could not prune OpenClaw compile cache: Error: locked", ); }); it("does not warn when compile-cache pruning hits EACCES or EPERM (shared caches)", () => { const base = path.join("/tmp", "openclaw-shared-compile-cache"); const dirA = path.join(base, "v22.13.1-x64-efe9a9df-1001"); const dirB = path.join(base, "v22.13.1-x64-efe9a9df-1002"); const warn = vi.fn(); const rmSync = vi.fn((value: string) => { if (value === dirA) { throw Object.assign(new Error(`permission denied pruning ${value}`), { code: "EACCES" }); } if (value === dirB) { throw Object.assign(new Error(`operation not permitted pruning ${value}`), { code: "EPERM", }); } }); pruneOpenClawCompileCache({ env: { NODE_COMPILE_CACHE: base }, existsSync: vi.fn((value: string) => value === base), readdirSync: vi.fn(() => [ { name: path.basename(dirA), isDirectory: () => true }, { name: path.basename(dirB), isDirectory: () => true }, ]), rmSync, log: { warn }, }); expect(rmSync).toHaveBeenCalledTimes(2); expect(warn).not.toHaveBeenCalled(); }); it("does not warn when the compile-cache base directory cannot be listed (EACCES)", () => { const base = path.join("/tmp", "openclaw-compile-cache-no-list"); const warn = vi.fn(); const rmSync = vi.fn(); const err = Object.assign(new Error(`EACCES: ${base}`), { code: "EACCES" }); pruneOpenClawCompileCache({ env: { NODE_COMPILE_CACHE: base }, existsSync: vi.fn(() => true), readdirSync: vi.fn(() => { throw err; }), rmSync, log: { warn }, }); expect(rmSync).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); }); it("does not classify published packages with source files as source checkouts", () => { const packageRoot = "/pkg"; const existingPaths = new Set([ path.join(packageRoot, "package.json"), path.join(packageRoot, "pnpm-workspace.yaml"), path.join(packageRoot, "src"), path.join(packageRoot, "extensions"), path.join(packageRoot, "dist", "postinstall-inventory.json"), ]); expect( isSourceCheckoutRoot({ packageRoot, existsSync: (value: string) => existingPaths.has(value), }), ).toBe(false); }); it("prunes source-checkout bundled plugin node_modules", async () => { const packageRoot = await createTempDirAsync("openclaw-source-checkout-"); const extensionsDir = path.join(packageRoot, "extensions"); await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true }); await fs.mkdir(path.join(packageRoot, "src"), { recursive: true }); await fs.mkdir(extensionsDir, { recursive: true }); await writePluginPackage(extensionsDir, "acpx", { dependencies: { acpx: "0.5.2", }, }); await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules", "acpx"), { recursive: true }); await fs.writeFile( path.join(extensionsDir, "acpx", "node_modules", "acpx", "package.json"), JSON.stringify({ name: "acpx", version: "0.4.1" }), ); runBundledPluginPostinstall({ env: { HOME: "/tmp/home" }, packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }); await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).rejects.toMatchObject({ code: "ENOENT", }); }); it("keeps source-checkout prune non-fatal", async () => { const packageRoot = await createTempDirAsync("openclaw-source-checkout-prune-error-"); const extensionsDir = path.join(packageRoot, "extensions"); await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true }); await fs.mkdir(path.join(packageRoot, "src"), { recursive: true }); await fs.mkdir(path.join(extensionsDir, "acpx"), { recursive: true }); await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n"); const warn = vi.fn(); expect(() => runBundledPluginPostinstall({ env: { HOME: "/tmp/home" }, packageRoot, rmSync: vi.fn(() => { throw new Error("locked"); }), log: { log: vi.fn(), warn }, }), ).not.toThrow(); expect(warn).toHaveBeenCalledWith( "[postinstall] could not prune bundled plugin source node_modules: Error: locked", ); }); it("does not prune user-state legacy runtime deps during source-checkout postinstall", async () => { const packageRoot = await createTempDirAsync("openclaw-source-checkout-state-skip-"); const home = await createTempDirAsync("openclaw-source-checkout-home-"); const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true }); await fs.mkdir(path.join(packageRoot, "src"), { recursive: true }); await fs.mkdir(path.join(packageRoot, "extensions"), { recursive: true }); await fs.mkdir(legacyRuntimeRoot, { recursive: true }); await fs.writeFile(path.join(legacyRuntimeRoot, "package.json"), "{}\n"); runBundledPluginPostinstall({ env: { HOME: home }, packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }); await expect(fs.stat(legacyRuntimeRoot)).resolves.toBeTruthy(); }); it("honors disable env before source-checkout pruning", async () => { const packageRoot = await createTempDirAsync("openclaw-source-checkout-disabled-"); const extensionsDir = path.join(packageRoot, "extensions"); await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true }); await fs.mkdir(path.join(packageRoot, "src"), { recursive: true }); await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules"), { recursive: true }); await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n"); runBundledPluginPostinstall({ env: { OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1" }, packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }); await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).resolves.toBeTruthy(); }); it("migrates the plugin registry during postinstall from built dist contracts", async () => { const packageRoot = await createTempDirAsync("openclaw-postinstall-registry-"); const log = { log: vi.fn(), warn: vi.fn() }; const migratePluginRegistryForInstall = vi.fn(async () => ({ status: "migrated", migrated: true, preflight: { deprecationWarnings: [], }, current: { plugins: [{ pluginId: "demo" }], }, })); const importModule = vi.fn(async (specifier: string) => { if (specifier.endsWith("/dist/commands/doctor/shared/plugin-registry-migration.js")) { return { migratePluginRegistryForInstall }; } throw new Error(`unexpected import: ${specifier}`); }); const result = await runPluginRegistryPostinstallMigration({ packageRoot, existsSync: vi.fn((filePath: string) => filePath.endsWith( path.join("dist", "commands", "doctor", "shared", "plugin-registry-migration.js"), ), ), importModule, env: { OPENCLAW_HOME: "/tmp/home" }, log, }); expect(result).toMatchObject({ status: "migrated" }); expect(migratePluginRegistryForInstall).toHaveBeenCalledWith({ env: { OPENCLAW_HOME: "/tmp/home" }, packageRoot, }); expect(log.log).toHaveBeenCalledWith( "[postinstall] migrated plugin registry: 1 plugin(s) indexed", ); }); it("surfaces deprecated plugin registry migration break-glass warnings", async () => { const warn = vi.fn(); const migratePluginRegistryForInstall = vi.fn(async () => ({ status: "skip-existing", migrated: false, preflight: { deprecationWarnings: ["OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated"], }, })); const importModule = vi.fn(async () => ({ migratePluginRegistryForInstall })); await runPluginRegistryPostinstallMigration({ packageRoot: "/pkg", existsSync: vi.fn(() => true), importModule, log: { log: vi.fn(), warn }, }); expect(warn).toHaveBeenCalledWith( "[postinstall] OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated", ); }); it("keeps plugin registry postinstall migration non-fatal when dist entries are unavailable", async () => { const warn = vi.fn(); await expect( runPluginRegistryPostinstallMigration({ packageRoot: "/pkg", existsSync: vi.fn(() => false), log: { log: vi.fn(), warn }, }), ).resolves.toEqual({ status: "skipped", reason: "missing-dist-entry", }); expect(warn).not.toHaveBeenCalled(); }); it("honors plugin registry postinstall migration disable env", async () => { const importModule = vi.fn(async () => { throw new Error("dist migration module should not import when migration is disabled"); }); await expect( runPluginRegistryPostinstallMigration({ packageRoot: "/pkg", env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "1" }, existsSync: vi.fn(() => true), importModule, log: { log: vi.fn(), warn: vi.fn() }, }), ).resolves.toMatchObject({ status: "disabled", migrated: false, reason: "disabled-env", }); expect(importModule).not.toHaveBeenCalled(); }); it("does not disable plugin registry migration for falsey env flag strings", async () => { const migratePluginRegistryForInstall = vi.fn(async () => ({ status: "skip-existing", migrated: false, preflight: {}, })); const importModule = vi.fn(async () => ({ migratePluginRegistryForInstall })); await expect( runPluginRegistryPostinstallMigration({ packageRoot: "/pkg", env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "0" }, existsSync: vi.fn(() => true), importModule, log: { log: vi.fn(), warn: vi.fn() }, }), ).resolves.toMatchObject({ status: "skip-existing", migrated: false, }); expect(importModule).toHaveBeenCalledOnce(); expect(migratePluginRegistryForInstall).toHaveBeenCalledWith({ env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "0" }, packageRoot: "/pkg", }); }); it("prunes stale dist files from packaged installs", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-"); const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js"); const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js"); await fs.mkdir(path.dirname(currentFile), { recursive: true }); await fs.writeFile(currentFile, "export {};\n"); await writePackageDistInventory(packageRoot); await fs.writeFile(staleFile, "export {};\n"); expect( pruneInstalledPackageDist({ packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }), ).toEqual(["dist/channel-CJUAgRQR.js"]); await expect(fs.stat(currentFile)).resolves.toBeTruthy(); await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); it("prunes legacy plugin runtime deps state during packaged postinstall", async () => { const prefix = await createTempDirAsync("openclaw-packaged-prefix-"); const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw"); const nodeModulesRoot = path.dirname(packageRoot); const home = await createTempDirAsync("openclaw-packaged-home-"); const stateOverride = path.join(home, "custom-state"); const systemState = path.join(home, "system-state"); const defaultLegacyRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); const oldBrandLegacyRoot = path.join(home, ".clawdbot", "plugin-runtime-deps"); const overrideLegacyRoot = path.join(stateOverride, "plugin-runtime-deps"); const systemLegacyRoot = path.join(systemState, "plugin-runtime-deps"); const thirdPartyNodeModules = path.join( home, ".openclaw", "extensions", "lossless-claw", "node_modules", ); const currentFile = path.join(packageRoot, "dist", "entry.js"); const legacySymlinkTarget = path.join( defaultLegacyRoot, "openclaw-2026.4.29-slack", "node_modules", "@slack", "web-api", ); const slackScope = path.join(nodeModulesRoot, "@slack"); const legacySymlink = path.join(slackScope, "web-api"); await fs.mkdir(path.dirname(currentFile), { recursive: true }); await fs.writeFile(currentFile, "export {};\n"); await writePackageDistInventory(packageRoot); for (const root of [ defaultLegacyRoot, oldBrandLegacyRoot, overrideLegacyRoot, systemLegacyRoot, thirdPartyNodeModules, ]) { await fs.mkdir(root, { recursive: true }); await fs.writeFile(path.join(root, "package.json"), "{}\n"); } await fs.mkdir(legacySymlinkTarget, { recursive: true }); await fs.mkdir(slackScope, { recursive: true }); await fs.symlink(legacySymlinkTarget, legacySymlink, "dir"); const log = { log: vi.fn(), warn: vi.fn() }; runBundledPluginPostinstall({ env: { HOME: home, OPENCLAW_STATE_DIR: stateOverride, STATE_DIRECTORY: systemState, }, packageRoot, existsSync: existsSyncWithoutGlobalCompileCache, log, }); await expect(fs.stat(defaultLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(oldBrandLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(overrideLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(systemLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.lstat(legacySymlink)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(thirdPartyNodeModules)).resolves.toBeTruthy(); expect(log.warn).not.toHaveBeenCalled(); expect(log.log).toHaveBeenCalledWith( expect.stringContaining("[postinstall] pruned legacy plugin runtime deps:"), ); }); it("prunes global plugin-runtime symlinks before deleting their legacy targets", async () => { const prefix = await createTempDirAsync("openclaw-packaged-prefix-"); const home = await createTempDirAsync("openclaw-packaged-home-"); const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw"); const nodeModulesRoot = path.dirname(packageRoot); const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); const legacyTarget = path.join( legacyRuntimeRoot, "openclaw-2026.4.29-slack", "node_modules", "@slack", "web-api", ); const slackScope = path.join(nodeModulesRoot, "@slack"); const slackLink = path.join(slackScope, "web-api"); await fs.mkdir(legacyTarget, { recursive: true }); await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n"); await fs.mkdir(slackScope, { recursive: true }); await fs.mkdir(packageRoot, { recursive: true }); await fs.symlink(legacyTarget, slackLink, "dir"); const log = { log: vi.fn(), warn: vi.fn() }; pruneLegacyPluginRuntimeDepsState({ env: { HOME: home }, packageRoot, log, }); await expect(fs.lstat(slackLink)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(legacyRuntimeRoot)).rejects.toMatchObject({ code: "ENOENT" }); expect(log.warn).not.toHaveBeenCalled(); expect(log.log).toHaveBeenCalledWith( expect.stringContaining("[postinstall] pruned legacy plugin runtime deps symlinks:"), ); }); it("keeps legacy plugin runtime deps cleanup non-fatal", () => { const warn = vi.fn(); expect(() => pruneLegacyPluginRuntimeDepsState({ env: { HOME: "/home/alice" }, existsSync: vi.fn(() => true), rmSync: vi.fn(() => { throw new Error("locked"); }), log: { log: vi.fn(), warn }, homedir: () => "/home/alice", }), ).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining( "[postinstall] could not prune legacy plugin runtime deps /home/alice/.openclaw/plugin-runtime-deps: Error: locked", ), ); }); it("resolves legacy plugin runtime deps roots from OpenClaw state env", () => { expect( collectLegacyPluginRuntimeDepsStateRoots({ env: { HOME: "/users/alice", OPENCLAW_HOME: "/srv/openclaw-home", OPENCLAW_CONFIG_PATH: "~/profile/openclaw.json", OPENCLAW_STATE_DIR: "~/state", STATE_DIRECTORY: "/var/lib/openclaw", }, homedir: () => "/users/alice", }), ).toEqual([ "/srv/openclaw-home/.clawdbot/plugin-runtime-deps", "/srv/openclaw-home/.openclaw/plugin-runtime-deps", "/srv/openclaw-home/profile/plugin-runtime-deps", "/srv/openclaw-home/state/plugin-runtime-deps", "/var/lib/openclaw/plugin-runtime-deps", ]); }); it("keeps imported dist chunks even when inventory is stale", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-import-"); const entryFile = path.join(packageRoot, "dist", "cli", "run-main.js"); const importedChunk = path.join(packageRoot, "dist", "memory-state-CcqRgDZU.js"); const staleFile = path.join(packageRoot, "dist", "memory-state-old.js"); await fs.mkdir(path.dirname(entryFile), { recursive: true }); await fs.writeFile(entryFile, 'await import("../memory-state-CcqRgDZU.js");\n'); await writePackageDistInventory(packageRoot); await fs.writeFile(importedChunk, "export {};\n"); await fs.writeFile(staleFile, "export {};\n"); expect( pruneInstalledPackageDist({ packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }), ).toEqual(["dist/memory-state-old.js"]); await expect(fs.stat(importedChunk)).resolves.toBeTruthy(); await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); it("does not abort dist pruning when a listed chunk disappears before import expansion", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-chunk-"); const entryFile = path.join(packageRoot, "dist", "control-ui", "assets", "instances.js"); const staleFile = path.join(packageRoot, "dist", "stale.js"); await fs.mkdir(path.dirname(entryFile), { recursive: true }); await fs.writeFile(entryFile, 'import "./chunk.js";\n'); await writePackageDistInventory(packageRoot); await fs.writeFile(staleFile, "export {};\n"); const readFileSync = vi.fn((filePath: string | Buffer | URL, options?: BufferEncoding) => { if (String(filePath).endsWith("dist/control-ui/assets/instances.js")) { const error = new Error("missing generated asset") as NodeJS.ErrnoException; error.code = "ENOENT"; throw error; } return readFileSyncOriginal(filePath, options); }); expect(() => pruneInstalledPackageDist({ packageRoot, readFileSync, log: { log: vi.fn(), warn: vi.fn() }, }), ).not.toThrow(); await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); it("prunes stale private QA files without restoring compat sidecars", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-qa-compat-"); const currentFile = path.join(packageRoot, "dist", "entry.js"); const stalePackage = path.join(packageRoot, "dist", "extensions", "qa-lab", "package.json"); const staleManifest = path.join( packageRoot, "dist", "extensions", "qa-lab", "openclaw.plugin.json", ); await fs.mkdir(path.dirname(stalePackage), { recursive: true }); await fs.writeFile(currentFile, "export {};\n"); await writePackageDistInventory(packageRoot); await fs.writeFile(stalePackage, "{}\n"); await fs.writeFile(staleManifest, "{}\n"); runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn: vi.fn() }, }); await expect(fs.stat(stalePackage)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(staleManifest)).rejects.toMatchObject({ code: "ENOENT" }); await expect( fs.stat(path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect( fs.stat(path.join(packageRoot, "dist", "extensions", "qa-channel", "package.json")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect( fs.stat(path.join(packageRoot, "dist", "extensions", "qa-channel", "openclaw.plugin.json")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect( fs.stat(path.join(packageRoot, "dist", "extensions", "qa-lab", "runtime-api.js")), ).rejects.toMatchObject({ code: "ENOENT" }); }); it("keeps packaged postinstall non-fatal when the dist inventory is missing", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-inventory-"); const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js"); await fs.mkdir(path.dirname(staleFile), { recursive: true }); await fs.writeFile(staleFile, "export {};\n"); const warn = vi.fn(); expect(() => runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), ).not.toThrow(); await expect(fs.stat(staleFile)).resolves.toBeTruthy(); expect(warn).toHaveBeenCalledWith( "[postinstall] skipping dist prune: missing dist inventory: dist/postinstall-inventory.json", ); }); it("keeps packaged postinstall non-fatal when the dist inventory is invalid", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-invalid-inventory-"); const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js"); const inventoryPath = path.join(packageRoot, "dist", "postinstall-inventory.json"); await fs.mkdir(path.dirname(currentFile), { recursive: true }); await fs.writeFile(currentFile, "export {};\n"); await fs.writeFile(inventoryPath, "{not-json}\n"); const warn = vi.fn(); expect(() => runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), ).not.toThrow(); await expect(fs.stat(currentFile)).resolves.toBeTruthy(); expect(warn).toHaveBeenCalledWith( "[postinstall] skipping dist prune: invalid dist inventory: dist/postinstall-inventory.json", ); }); it("rejects symlinked dist roots in packaged installs", () => { expect(() => pruneInstalledPackageDist({ packageRoot: "/pkg", expectedFiles: new Set(), existsSync: vi.fn(() => true), lstatSync: vi.fn((filePath) => ({ isDirectory: () => filePath === "/pkg/dist", isSymbolicLink: () => filePath === "/pkg/dist", })), realpathSync: vi.fn((filePath) => filePath), readdirSync: vi.fn(), rmSync: vi.fn(), log: { log: vi.fn(), warn: vi.fn() }, }), ).toThrow("unsafe dist root: dist must be a real directory"); }); it("rejects symlink entries in packaged dist trees", () => { expect(() => pruneInstalledPackageDist({ packageRoot: "/pkg", expectedFiles: new Set(), existsSync: vi.fn(() => true), lstatSync: vi.fn(() => ({ isDirectory: () => true, isSymbolicLink: () => false, })), realpathSync: vi.fn((filePath) => filePath), readdirSync: vi.fn((filePath) => { if (filePath === "/pkg/dist") { return [ { name: "escape", isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true, }, ]; } return []; }), rmSync: vi.fn(), log: { log: vi.fn(), warn: vi.fn() }, }), ).toThrow("unsafe dist entry: dist/escape"); }); it("prunes stale bundled plugin dependency debris from packaged dist", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-dist-prune-"); const staleFile = path.join(packageRoot, "dist", "stale-runtime.js"); const packageJson = path.join(packageRoot, "dist", "extensions", "slack", "package.json"); const binDir = path.join(packageRoot, "dist", "extensions", "slack", "node_modules", ".bin"); const dependencyFile = path.join( packageRoot, "dist", "extensions", "slack", "node_modules", "typebox", "package.json", ); const installStageFile = path.join( packageRoot, "dist", "extensions", "slack", ".openclaw-install-stage", "node_modules", "typebox", "build", "compile", "code.mjs", ); const retryInstallStageFile = path.join( packageRoot, "dist", "extensions", "slack", ".openclaw-install-stage-retry", "node_modules", "typebox", "build", "compile", "code.mjs", ); await fs.mkdir(path.dirname(staleFile), { recursive: true }); await fs.mkdir(path.dirname(packageJson), { recursive: true }); await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(path.dirname(dependencyFile), { recursive: true }); await fs.mkdir(path.dirname(installStageFile), { recursive: true }); await fs.mkdir(path.dirname(retryInstallStageFile), { recursive: true }); await fs.writeFile(staleFile, "export {};\n"); await fs.writeFile(packageJson, "{}\n"); await fs.writeFile(dependencyFile, "{}\n"); await fs.writeFile(installStageFile, "export {};\n"); await fs.writeFile(retryInstallStageFile, "export {};\n"); await fs.symlink("../fxparser/bin.js", path.join(binDir, "fxparser")); expect( pruneInstalledPackageDist({ packageRoot, expectedFiles: new Set(["dist/extensions/slack/package.json"]), log: { log: vi.fn(), warn: vi.fn() }, }), ).toEqual(["dist/stale-runtime.js"]); await expect( fs.stat(path.join(packageRoot, "dist", "extensions", "slack", "node_modules")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(path.dirname(installStageFile))).rejects.toMatchObject({ code: "ENOENT", }); await expect(fs.stat(path.dirname(retryInstallStageFile))).rejects.toMatchObject({ code: "ENOENT", }); }); it("unlinks stale files instead of recursive pruning them", () => { const unlinkSync = vi.fn(); expect( pruneInstalledPackageDist({ packageRoot: "/pkg", expectedFiles: new Set(), existsSync: vi.fn(() => true), lstatSync: vi.fn(() => ({ isDirectory: () => true, isSymbolicLink: () => false, })), realpathSync: vi.fn((filePath) => filePath), readdirSync: vi.fn((filePath, options) => { if (filePath === "/pkg/dist" && options?.withFileTypes) { return [ { name: "stale.js", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, }, ]; } return []; }), unlinkSync, log: { log: vi.fn(), warn: vi.fn() }, }), ).toEqual(["dist/stale.js"]); expect(unlinkSync).toHaveBeenCalledWith("/pkg/dist/stale.js"); }); it("prunes only bundled plugin package node_modules in source checkouts", async () => { const packageRoot = await createTempDirAsync("openclaw-source-prune-"); const extensionsDir = path.join(packageRoot, "extensions"); await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules"), { recursive: true }); await fs.mkdir(path.join(extensionsDir, "fixtures", "node_modules"), { recursive: true }); await fs.writeFile( path.join(extensionsDir, "acpx", "package.json"), JSON.stringify({ name: "@openclaw/acpx" }), ); pruneBundledPluginSourceNodeModules({ extensionsDir }); await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).rejects.toMatchObject({ code: "ENOENT", }); await expect( fs.stat(path.join(extensionsDir, "fixtures", "node_modules")), ).resolves.toBeTruthy(); }); it("skips symlink entries when pruning source-checkout bundled plugin node_modules", () => { const removePath = vi.fn(); pruneBundledPluginSourceNodeModules({ extensionsDir: "/repo/extensions", existsSync: vi.fn((value) => value === "/repo/extensions"), readdirSync: vi.fn(() => [ { name: "acpx", isDirectory: () => true, isSymbolicLink: () => true, }, ]), rmSync: removePath, }); expect(removePath).not.toHaveBeenCalled(); }); });