From 52b57d0953413891c0fe61e120e6fd88da196ee9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 01:16:38 +0100 Subject: [PATCH] fix(cli): scope packaged compile cache --- CHANGELOG.md | 1 + openclaw.mjs | 77 ++++++++++++++++++++++- scripts/lib/workspace-bootstrap-smoke.mjs | 51 ++++++++++++--- scripts/release-check.ts | 29 +++++++-- src/entry.compile-cache.test.ts | 16 +++++ src/entry.compile-cache.ts | 54 +++++++++++++++- test/openclaw-launcher.e2e.test.ts | 26 +++++++- test/release-check.test.ts | 67 +++++++++++++++++++- 8 files changed, 302 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6738589f2b5..1438b06d5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash. - Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc. - CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm. - Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc. diff --git a/openclaw.mjs b/openclaw.mjs index b280daed5c9..1ceda9c2246 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; +import os from "node:os"; +import path from "node:path"; import { fileURLToPath } from "node:url"; const MIN_NODE_MAJOR = 22; @@ -46,6 +48,41 @@ const isSourceCheckoutLauncher = () => const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined; const isNodeCompileCacheRequested = () => process.env.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled(); +const sanitizeCompileCachePathSegment = (value) => { + const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized : "unknown"; +}; +const readPackageVersion = () => { + try { + const parsed = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")); + if (typeof parsed?.version === "string" && parsed.version.trim().length > 0) { + return parsed.version; + } + } catch { + // Fall through to an install-metadata-only cache key. + } + return "unknown"; +}; +const resolvePackagedCompileCacheDirectory = () => { + const packageJsonUrl = new URL("./package.json", import.meta.url); + const version = sanitizeCompileCachePathSegment(readPackageVersion()); + let installMarker = "no-package-json"; + try { + const stat = statSync(packageJsonUrl); + installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`; + } catch { + // Package archives should always have package.json, but keep startup best-effort. + } + const baseDirectory = isNodeCompileCacheRequested() + ? process.env.NODE_COMPILE_CACHE + : path.join(os.tmpdir(), "node-compile-cache"); + return path.join( + baseDirectory, + "openclaw", + version, + sanitizeCompileCachePathSegment(installMarker), + ); +}; const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { @@ -79,10 +116,46 @@ const respawnWithoutCompileCacheIfNeeded = () => { respawnWithoutCompileCacheIfNeeded(); +const respawnWithPackagedCompileCacheIfNeeded = () => { + if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) { + return false; + } + if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") { + return false; + } + const currentDirectory = module.getCompileCacheDir?.(); + if (!currentDirectory) { + return false; + } + const desiredDirectory = resolvePackagedCompileCacheDirectory(); + if (path.resolve(currentDirectory) === path.resolve(desiredDirectory)) { + return false; + } + const env = { + ...process.env, + NODE_COMPILE_CACHE: desiredDirectory, + OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1", + }; + const result = spawnSync( + process.execPath, + [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], + { + stdio: "inherit", + env, + }, + ); + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); +}; + +respawnWithPackagedCompileCacheIfNeeded(); + // https://nodejs.org/api/module.html#module-compile-cache if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) { try { - module.enableCompileCache(); + module.enableCompileCache(resolvePackagedCompileCacheDirectory()); } catch { // Ignore errors } diff --git a/scripts/lib/workspace-bootstrap-smoke.mjs b/scripts/lib/workspace-bootstrap-smoke.mjs index 7d96007f368..9a62f4750eb 100644 --- a/scripts/lib/workspace-bootstrap-smoke.mjs +++ b/scripts/lib/workspace-bootstrap-smoke.mjs @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; export const WORKSPACE_TEMPLATE_PACK_PATHS = [ "docs/reference/templates/AGENTS.md", @@ -23,6 +23,47 @@ const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [ "BOOTSTRAP.md", ]; +export const WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS = 15_000; +const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin"; + +export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) { + const allowlistedEnvEntries = [ + "TMPDIR", + "TMP", + "TEMP", + "SystemRoot", + "ComSpec", + "PATHEXT", + "WINDIR", + ]; + const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows"; + const nodeBinDir = dirname(process.execPath); + const safePath = + process.platform === "win32" + ? `${nodeBinDir};${windowsRoot}\\System32;${windowsRoot}` + : `${nodeBinDir}:${SAFE_UNIX_SMOKE_PATH}`; + + return { + ...Object.fromEntries( + allowlistedEnvEntries.flatMap((key) => { + const value = env[key]; + return typeof value === "string" && value.length > 0 ? [[key, value]] : []; + }), + ), + PATH: safePath, + HOME: homeDir, + USERPROFILE: homeDir, + OPENCLAW_HOME: homeDir, + OPENCLAW_NO_ONBOARD: "1", + OPENCLAW_SUPPRESS_NOTES: "1", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + AWS_EC2_METADATA_DISABLED: "true", + AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"), + AWS_CONFIG_FILE: join(homeDir, ".aws", "config"), + ...overrides, + }; +} + function collectMissingBootstrapWorkspaceFiles(workspaceDir) { return REQUIRED_BOOTSTRAP_WORKSPACE_FILES.filter( (filename) => !existsSync(join(workspaceDir, filename)), @@ -77,12 +118,8 @@ export function runInstalledWorkspaceBootstrapSmoke(params) { encoding: "utf8", maxBuffer: 1024 * 1024 * 16, stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - HOME: homeDir, - OPENCLAW_HOME: homeDir, - OPENCLAW_SUPPRESS_NOTES: "1", - }, + timeout: WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS, + env: createWorkspaceBootstrapSmokeEnv(process.env, homeDir), }, ); } catch (error) { diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 98faae157c7..f73bc6ae268 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,6 +15,7 @@ import { import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; import { isBundledRuntimeDepsInstallStagePath, LOCAL_BUILD_METADATA_DIST_PATHS, @@ -134,6 +135,12 @@ export const PACKED_CLI_SMOKE_COMMANDS = [ ["config", "schema"], ["models", "list", "--provider", "amazon-bedrock"], ] as const; +export const PACKED_COMPLETION_SMOKE_ARGS = [ + "completion", + "--write-state", + "--shell", + "zsh", +] as const; function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); @@ -318,6 +325,19 @@ export function createPackedCliSmokeEnv( }; } +export function createPackedCompletionSmokeEnv( + env: NodeJS.ProcessEnv, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return { + ...env, + ...overrides, + OPENCLAW_SUPPRESS_NOTES: "1", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + [COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1", + }; +} + function runPackedBundledPluginPostinstall(packageRoot: string): void { execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], { cwd: packageRoot, @@ -599,17 +619,14 @@ function runPackedBundledChannelEntrySmoke(): void { execFileSync( process.execPath, - [join(packageRoot, "openclaw.mjs"), "completion", "--write-state"], + [join(packageRoot, "openclaw.mjs"), ...PACKED_COMPLETION_SMOKE_ARGS], { cwd: packageRoot, stdio: "inherit", - env: { - ...process.env, + env: createPackedCompletionSmokeEnv(process.env, { HOME: homeDir, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_SUPPRESS_NOTES: "1", - OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", - }, + }), }, ); diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index 87af57b24e2..c43f1d9f3e7 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -5,6 +5,7 @@ import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js"; import { buildOpenClawCompileCacheRespawnPlan, isSourceCheckoutInstallRoot, + resolveOpenClawCompileCacheDirectory, resolveEntryInstallRoot, shouldEnableOpenClawCompileCache, } from "./entry.compile-cache.js"; @@ -54,6 +55,21 @@ describe("entry compile cache", () => { ).toBe(false); }); + it("scopes packaged compile cache by package install metadata", async () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-key-"); + const packageJsonPath = path.join(root, "package.json"); + await fs.writeFile(packageJsonPath, '{"version":"2026.4.29"}\n', "utf8"); + + const directory = resolveOpenClawCompileCacheDirectory({ + env: { NODE_COMPILE_CACHE: path.join(root, ".node-cache") }, + installRoot: root, + }); + + expect(directory).toContain(path.join(".node-cache", "openclaw")); + expect(directory).toContain("2026.4.29"); + expect(path.basename(directory)).toMatch(/^\d+-\d+$/); + }); + it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => { const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-"); await fs.mkdir(path.join(root, "src"), { recursive: true }); diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index c03399b51bc..fb757ad14ad 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { enableCompileCache, getCompileCacheDir } from "node:module"; +import os from "node:os"; import path from "node:path"; export function resolveEntryInstallRoot(entryFile: string): string { @@ -34,6 +35,55 @@ export function shouldEnableOpenClawCompileCache(params: { return !isSourceCheckoutInstallRoot(params.installRoot); } +function sanitizeCompileCachePathSegment(value: string): string { + const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized : "unknown"; +} + +function readPackageVersion(packageJsonPath: string): string { + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown; + if ( + parsed && + typeof parsed === "object" && + "version" in parsed && + typeof parsed.version === "string" && + parsed.version.trim().length > 0 + ) { + return parsed.version; + } + } catch { + // Fall through to an install-metadata-only cache key. + } + return "unknown"; +} + +export function resolveOpenClawCompileCacheDirectory(params: { + env?: NodeJS.ProcessEnv; + installRoot: string; +}): string { + const env = params.env ?? process.env; + const packageJsonPath = path.join(params.installRoot, "package.json"); + const version = sanitizeCompileCachePathSegment(readPackageVersion(packageJsonPath)); + let installMarker = "no-package-json"; + try { + const stat = statSync(packageJsonPath); + installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`; + } catch { + // Package archives should always have package.json, but keep startup best-effort. + } + const baseDirectory = + env.NODE_COMPILE_CACHE && !isNodeCompileCacheDisabled(env) + ? env.NODE_COMPILE_CACHE + : path.join(os.tmpdir(), "node-compile-cache"); + return path.join( + baseDirectory, + "openclaw", + version, + sanitizeCompileCachePathSegment(installMarker), + ); +} + export type OpenClawCompileCacheRespawnPlan = { command: string; args: string[]; @@ -107,7 +157,7 @@ export function enableOpenClawCompileCache(params: { return; } try { - enableCompileCache(); + enableCompileCache(resolveOpenClawCompileCacheDirectory(params)); } catch { // Best-effort only; never block startup. } diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 3282ef5a70b..8778c58731b 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -161,7 +161,7 @@ describe("openclaw launcher", () => { }, ); - it("does not respawn packaged launchers when NODE_COMPILE_CACHE is configured", async () => { + it("keeps compile cache enabled for packaged launchers when NODE_COMPILE_CACHE is configured", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); await addCompileCacheProbe(fixtureRoot); @@ -177,6 +177,30 @@ describe("openclaw launcher", () => { expect(result.stdout).toBe("cache:enabled;respawn:0"); }); + it("scopes packaged launcher compile cache inside configured cache roots", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.4.29"}\n'); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import module from "node:module";', + 'process.stdout.write(module.getCompileCacheDir?.() ?? "cache:disabled");', + ].join("\n"), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(path.join(".node-compile-cache", "openclaw", "2026.4.29")); + }); + it("enables compile cache for packaged launchers", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); await addCompileCacheProbe(fixtureRoot); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index f330bdeb430..bcd4618d386 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -5,7 +5,10 @@ import { bundledDistPluginFile, bundledPluginFile } from "openclaw/plugin-sdk/te import { describe, expect, it } from "vitest"; import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs"; import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs"; -import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs"; +import { + WORKSPACE_TEMPLATE_PACK_PATHS, + createWorkspaceBootstrapSmokeEnv, +} from "../scripts/lib/workspace-bootstrap-smoke.mjs"; import { collectInstalledRootDependencyManifestErrors } from "../scripts/openclaw-npm-postpublish-verify.ts"; import { collectAppcastSparkleVersionErrors, @@ -20,13 +23,16 @@ import { collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, + createPackedCompletionSmokeEnv, createPackedCliSmokeEnv, createPackedBundledPluginPostinstallEnv, MAX_CRITICAL_PLUGIN_SDK_ENTRYPOINT_BYTES, PACKED_CLI_SMOKE_COMMANDS, + PACKED_COMPLETION_SMOKE_ARGS, packageNameFromSpecifier, resolveMissingPackBuildHint, } from "../scripts/release-check.ts"; +import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; import { LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, @@ -77,6 +83,10 @@ describe("packed CLI smoke", () => { ]); }); + it("keeps packed completion smoke scoped to one shell cache", () => { + expect(PACKED_COMPLETION_SMOKE_ARGS).toEqual(["completion", "--write-state", "--shell", "zsh"]); + }); + it("builds a packed CLI smoke env with packaged-install guardrails", () => { expect( createPackedCliSmokeEnv( @@ -113,6 +123,61 @@ describe("packed CLI smoke", () => { OPENCLAW_STATE_DIR: "/tmp/smoke-state", }); }); + + it("skips plugin command discovery during packed completion cache smoke", () => { + expect( + createPackedCompletionSmokeEnv( + { + PATH: "/usr/bin", + OPENCLAW_COMPLETION_SKIP_PLUGIN_COMMANDS: "0", + }, + { + HOME: "/tmp/smoke-home", + OPENCLAW_STATE_DIR: "/tmp/smoke-state", + }, + ), + ).toMatchObject({ + PATH: "/usr/bin", + HOME: "/tmp/smoke-home", + OPENCLAW_STATE_DIR: "/tmp/smoke-state", + OPENCLAW_SUPPRESS_NOTES: "1", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + [COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1", + }); + }); +}); + +describe("workspace bootstrap smoke", () => { + it("runs with a sterile env instead of maintainer provider credentials", () => { + expect( + createWorkspaceBootstrapSmokeEnv( + { + PATH: "/usr/bin", + HOME: "/tmp/original-home", + TMPDIR: "/tmp/original-tmp", + OPENAI_API_KEY: "real-secret", + ANTHROPIC_API_KEY: "real-secret", + OPENCLAW_CONFIG_PATH: "/tmp/leaky-config.json", + }, + "/tmp/bootstrap-home", + ), + ).toEqual({ + PATH: + process.platform === "win32" + ? `${dirname(process.execPath)};C:\\Windows\\System32;C:\\Windows` + : `${dirname(process.execPath)}:/usr/bin:/bin`, + HOME: "/tmp/bootstrap-home", + USERPROFILE: "/tmp/bootstrap-home", + OPENCLAW_HOME: "/tmp/bootstrap-home", + TMPDIR: "/tmp/original-tmp", + OPENCLAW_NO_ONBOARD: "1", + OPENCLAW_SUPPRESS_NOTES: "1", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + AWS_EC2_METADATA_DISABLED: "true", + AWS_SHARED_CREDENTIALS_FILE: "/tmp/bootstrap-home/.aws/credentials", + AWS_CONFIG_FILE: "/tmp/bootstrap-home/.aws/config", + }); + }); }); describe("collectBundledExtensionManifestErrors", () => {