Skip to content

Commit 52b57d0

Browse files
committed
fix(cli): scope packaged compile cache
1 parent 0b59964 commit 52b57d0

8 files changed

Lines changed: 302 additions & 19 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828

2929
### Fixes
3030

31+
- 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.
3132
- 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.
3233
- 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.
3334
- 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.

openclaw.mjs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
#!/usr/bin/env node
22

33
import { spawnSync } from "node:child_process";
4-
import { existsSync, readFileSync } from "node:fs";
4+
import { existsSync, readFileSync, statSync } from "node:fs";
55
import { access } from "node:fs/promises";
66
import module from "node:module";
7+
import os from "node:os";
8+
import path from "node:path";
79
import { fileURLToPath } from "node:url";
810

911
const MIN_NODE_MAJOR = 22;
@@ -46,6 +48,41 @@ const isSourceCheckoutLauncher = () =>
4648
const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined;
4749
const isNodeCompileCacheRequested = () =>
4850
process.env.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled();
51+
const sanitizeCompileCachePathSegment = (value) => {
52+
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
53+
return normalized.length > 0 ? normalized : "unknown";
54+
};
55+
const readPackageVersion = () => {
56+
try {
57+
const parsed = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
58+
if (typeof parsed?.version === "string" && parsed.version.trim().length > 0) {
59+
return parsed.version;
60+
}
61+
} catch {
62+
// Fall through to an install-metadata-only cache key.
63+
}
64+
return "unknown";
65+
};
66+
const resolvePackagedCompileCacheDirectory = () => {
67+
const packageJsonUrl = new URL("./package.json", import.meta.url);
68+
const version = sanitizeCompileCachePathSegment(readPackageVersion());
69+
let installMarker = "no-package-json";
70+
try {
71+
const stat = statSync(packageJsonUrl);
72+
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
73+
} catch {
74+
// Package archives should always have package.json, but keep startup best-effort.
75+
}
76+
const baseDirectory = isNodeCompileCacheRequested()
77+
? process.env.NODE_COMPILE_CACHE
78+
: path.join(os.tmpdir(), "node-compile-cache");
79+
return path.join(
80+
baseDirectory,
81+
"openclaw",
82+
version,
83+
sanitizeCompileCachePathSegment(installMarker),
84+
);
85+
};
4986

5087
const respawnWithoutCompileCacheIfNeeded = () => {
5188
if (!isSourceCheckoutLauncher()) {
@@ -79,10 +116,46 @@ const respawnWithoutCompileCacheIfNeeded = () => {
79116

80117
respawnWithoutCompileCacheIfNeeded();
81118

119+
const respawnWithPackagedCompileCacheIfNeeded = () => {
120+
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
121+
return false;
122+
}
123+
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
124+
return false;
125+
}
126+
const currentDirectory = module.getCompileCacheDir?.();
127+
if (!currentDirectory) {
128+
return false;
129+
}
130+
const desiredDirectory = resolvePackagedCompileCacheDirectory();
131+
if (path.resolve(currentDirectory) === path.resolve(desiredDirectory)) {
132+
return false;
133+
}
134+
const env = {
135+
...process.env,
136+
NODE_COMPILE_CACHE: desiredDirectory,
137+
OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1",
138+
};
139+
const result = spawnSync(
140+
process.execPath,
141+
[...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
142+
{
143+
stdio: "inherit",
144+
env,
145+
},
146+
);
147+
if (result.error) {
148+
throw result.error;
149+
}
150+
process.exit(result.status ?? 1);
151+
};
152+
153+
respawnWithPackagedCompileCacheIfNeeded();
154+
82155
// https://nodejs.org/api/module.html#module-compile-cache
83156
if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) {
84157
try {
85-
module.enableCompileCache();
158+
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
86159
} catch {
87160
// Ignore errors
88161
}

scripts/lib/workspace-bootstrap-smoke.mjs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFileSync } from "node:child_process";
22
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { dirname, join } from "node:path";
55

66
export const WORKSPACE_TEMPLATE_PACK_PATHS = [
77
"docs/reference/templates/AGENTS.md",
@@ -23,6 +23,47 @@ const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [
2323
"BOOTSTRAP.md",
2424
];
2525

26+
export const WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS = 15_000;
27+
const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin";
28+
29+
export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) {
30+
const allowlistedEnvEntries = [
31+
"TMPDIR",
32+
"TMP",
33+
"TEMP",
34+
"SystemRoot",
35+
"ComSpec",
36+
"PATHEXT",
37+
"WINDIR",
38+
];
39+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
40+
const nodeBinDir = dirname(process.execPath);
41+
const safePath =
42+
process.platform === "win32"
43+
? `${nodeBinDir};${windowsRoot}\\System32;${windowsRoot}`
44+
: `${nodeBinDir}:${SAFE_UNIX_SMOKE_PATH}`;
45+
46+
return {
47+
...Object.fromEntries(
48+
allowlistedEnvEntries.flatMap((key) => {
49+
const value = env[key];
50+
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
51+
}),
52+
),
53+
PATH: safePath,
54+
HOME: homeDir,
55+
USERPROFILE: homeDir,
56+
OPENCLAW_HOME: homeDir,
57+
OPENCLAW_NO_ONBOARD: "1",
58+
OPENCLAW_SUPPRESS_NOTES: "1",
59+
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
60+
AWS_EC2_METADATA_DISABLED: "true",
61+
AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"),
62+
AWS_CONFIG_FILE: join(homeDir, ".aws", "config"),
63+
...overrides,
64+
};
65+
}
66+
2667
function collectMissingBootstrapWorkspaceFiles(workspaceDir) {
2768
return REQUIRED_BOOTSTRAP_WORKSPACE_FILES.filter(
2869
(filename) => !existsSync(join(workspaceDir, filename)),
@@ -77,12 +118,8 @@ export function runInstalledWorkspaceBootstrapSmoke(params) {
77118
encoding: "utf8",
78119
maxBuffer: 1024 * 1024 * 16,
79120
stdio: ["ignore", "pipe", "pipe"],
80-
env: {
81-
...process.env,
82-
HOME: homeDir,
83-
OPENCLAW_HOME: homeDir,
84-
OPENCLAW_SUPPRESS_NOTES: "1",
85-
},
121+
timeout: WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS,
122+
env: createWorkspaceBootstrapSmokeEnv(process.env, homeDir),
86123
},
87124
);
88125
} catch (error) {

scripts/release-check.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { tmpdir } from "node:os";
1616
import { dirname, join, resolve } from "node:path";
1717
import { pathToFileURL } from "node:url";
18+
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts";
1819
import {
1920
isBundledRuntimeDepsInstallStagePath,
2021
LOCAL_BUILD_METADATA_DIST_PATHS,
@@ -134,6 +135,12 @@ export const PACKED_CLI_SMOKE_COMMANDS = [
134135
["config", "schema"],
135136
["models", "list", "--provider", "amazon-bedrock"],
136137
] as const;
138+
export const PACKED_COMPLETION_SMOKE_ARGS = [
139+
"completion",
140+
"--write-state",
141+
"--shell",
142+
"zsh",
143+
] as const;
137144

138145
function collectBundledExtensions(): BundledExtension[] {
139146
const extensionsDir = resolve("extensions");
@@ -318,6 +325,19 @@ export function createPackedCliSmokeEnv(
318325
};
319326
}
320327

328+
export function createPackedCompletionSmokeEnv(
329+
env: NodeJS.ProcessEnv,
330+
overrides: NodeJS.ProcessEnv = {},
331+
): NodeJS.ProcessEnv {
332+
return {
333+
...env,
334+
...overrides,
335+
OPENCLAW_SUPPRESS_NOTES: "1",
336+
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
337+
[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1",
338+
};
339+
}
340+
321341
function runPackedBundledPluginPostinstall(packageRoot: string): void {
322342
execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], {
323343
cwd: packageRoot,
@@ -599,17 +619,14 @@ function runPackedBundledChannelEntrySmoke(): void {
599619

600620
execFileSync(
601621
process.execPath,
602-
[join(packageRoot, "openclaw.mjs"), "completion", "--write-state"],
622+
[join(packageRoot, "openclaw.mjs"), ...PACKED_COMPLETION_SMOKE_ARGS],
603623
{
604624
cwd: packageRoot,
605625
stdio: "inherit",
606-
env: {
607-
...process.env,
626+
env: createPackedCompletionSmokeEnv(process.env, {
608627
HOME: homeDir,
609628
OPENCLAW_STATE_DIR: stateDir,
610-
OPENCLAW_SUPPRESS_NOTES: "1",
611-
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
612-
},
629+
}),
613630
},
614631
);
615632

src/entry.compile-cache.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js";
55
import {
66
buildOpenClawCompileCacheRespawnPlan,
77
isSourceCheckoutInstallRoot,
8+
resolveOpenClawCompileCacheDirectory,
89
resolveEntryInstallRoot,
910
shouldEnableOpenClawCompileCache,
1011
} from "./entry.compile-cache.js";
@@ -54,6 +55,21 @@ describe("entry compile cache", () => {
5455
).toBe(false);
5556
});
5657

58+
it("scopes packaged compile cache by package install metadata", async () => {
59+
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-key-");
60+
const packageJsonPath = path.join(root, "package.json");
61+
await fs.writeFile(packageJsonPath, '{"version":"2026.4.29"}\n', "utf8");
62+
63+
const directory = resolveOpenClawCompileCacheDirectory({
64+
env: { NODE_COMPILE_CACHE: path.join(root, ".node-cache") },
65+
installRoot: root,
66+
});
67+
68+
expect(directory).toContain(path.join(".node-cache", "openclaw"));
69+
expect(directory).toContain("2026.4.29");
70+
expect(path.basename(directory)).toMatch(/^\d+-\d+$/);
71+
});
72+
5773
it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => {
5874
const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-");
5975
await fs.mkdir(path.join(root, "src"), { recursive: true });

src/entry.compile-cache.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawnSync } from "node:child_process";
2-
import { existsSync } from "node:fs";
2+
import { existsSync, readFileSync, statSync } from "node:fs";
33
import { enableCompileCache, getCompileCacheDir } from "node:module";
4+
import os from "node:os";
45
import path from "node:path";
56

67
export function resolveEntryInstallRoot(entryFile: string): string {
@@ -34,6 +35,55 @@ export function shouldEnableOpenClawCompileCache(params: {
3435
return !isSourceCheckoutInstallRoot(params.installRoot);
3536
}
3637

38+
function sanitizeCompileCachePathSegment(value: string): string {
39+
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
40+
return normalized.length > 0 ? normalized : "unknown";
41+
}
42+
43+
function readPackageVersion(packageJsonPath: string): string {
44+
try {
45+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown;
46+
if (
47+
parsed &&
48+
typeof parsed === "object" &&
49+
"version" in parsed &&
50+
typeof parsed.version === "string" &&
51+
parsed.version.trim().length > 0
52+
) {
53+
return parsed.version;
54+
}
55+
} catch {
56+
// Fall through to an install-metadata-only cache key.
57+
}
58+
return "unknown";
59+
}
60+
61+
export function resolveOpenClawCompileCacheDirectory(params: {
62+
env?: NodeJS.ProcessEnv;
63+
installRoot: string;
64+
}): string {
65+
const env = params.env ?? process.env;
66+
const packageJsonPath = path.join(params.installRoot, "package.json");
67+
const version = sanitizeCompileCachePathSegment(readPackageVersion(packageJsonPath));
68+
let installMarker = "no-package-json";
69+
try {
70+
const stat = statSync(packageJsonPath);
71+
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
72+
} catch {
73+
// Package archives should always have package.json, but keep startup best-effort.
74+
}
75+
const baseDirectory =
76+
env.NODE_COMPILE_CACHE && !isNodeCompileCacheDisabled(env)
77+
? env.NODE_COMPILE_CACHE
78+
: path.join(os.tmpdir(), "node-compile-cache");
79+
return path.join(
80+
baseDirectory,
81+
"openclaw",
82+
version,
83+
sanitizeCompileCachePathSegment(installMarker),
84+
);
85+
}
86+
3787
export type OpenClawCompileCacheRespawnPlan = {
3888
command: string;
3989
args: string[];
@@ -107,7 +157,7 @@ export function enableOpenClawCompileCache(params: {
107157
return;
108158
}
109159
try {
110-
enableCompileCache();
160+
enableCompileCache(resolveOpenClawCompileCacheDirectory(params));
111161
} catch {
112162
// Best-effort only; never block startup.
113163
}

test/openclaw-launcher.e2e.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ describe("openclaw launcher", () => {
161161
},
162162
);
163163

164-
it("does not respawn packaged launchers when NODE_COMPILE_CACHE is configured", async () => {
164+
it("keeps compile cache enabled for packaged launchers when NODE_COMPILE_CACHE is configured", async () => {
165165
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
166166
await addCompileCacheProbe(fixtureRoot);
167167

@@ -177,6 +177,30 @@ describe("openclaw launcher", () => {
177177
expect(result.stdout).toBe("cache:enabled;respawn:0");
178178
});
179179

180+
it("scopes packaged launcher compile cache inside configured cache roots", async () => {
181+
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
182+
await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.4.29"}\n');
183+
await fs.writeFile(
184+
path.join(fixtureRoot, "dist", "entry.js"),
185+
[
186+
'import module from "node:module";',
187+
'process.stdout.write(module.getCompileCacheDir?.() ?? "cache:disabled");',
188+
].join("\n"),
189+
"utf8",
190+
);
191+
192+
const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], {
193+
cwd: fixtureRoot,
194+
env: launcherEnv({
195+
NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"),
196+
}),
197+
encoding: "utf8",
198+
});
199+
200+
expect(result.status).toBe(0);
201+
expect(result.stdout).toContain(path.join(".node-compile-cache", "openclaw", "2026.4.29"));
202+
});
203+
180204
it("enables compile cache for packaged launchers", async () => {
181205
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
182206
await addCompileCacheProbe(fixtureRoot);

0 commit comments

Comments
 (0)