fix(cli): scope packaged compile cache

This commit is contained in:
Peter Steinberger
2026-04-30 01:16:38 +01:00
parent 0b59964ec9
commit 52b57d0953
8 changed files with 302 additions and 19 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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",
},
}),
},
);

View File

@@ -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 });

View File

@@ -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.
}

View File

@@ -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);

View File

@@ -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", () => {