fix(plugins): harden runtime dependency repair

This commit is contained in:
Peter Steinberger
2026-04-25 02:07:13 +01:00
parent cc0f3067a0
commit 8262735354
6 changed files with 163 additions and 44 deletions

View File

@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/runtime deps: isolate the internal npm cache used for bundled plugin runtime-dependency repair and let package updates refresh/verify already-current installs, so failed update or sudo doctor runs can be repaired by rerunning `openclaw update`. Thanks @steipete.
- Plugins/runtime deps: stage bundled plugin runtime dependencies for packaged/global installs in an external runtime root and retain already staged deps across repairs, avoiding package-tree update races and npm pruning after upgrades. Thanks @steipete.
- Plugins/runtime deps: log bundled plugin runtime-dependency staging before synchronous npm installs start and include elapsed timing afterward, so first boot after upgrades no longer looks hung while dependencies are being repaired. Thanks @steipete.
- Agents/failover: forward embedded run abort signals into provider-owned model streams, cap implicit LLM idle watchdogs below long run timeouts, and mark 429 responses without usable retry timing as non-retryable so GitHub Copilot rate limits fail over or surface promptly instead of hanging until run timeout. Fixes #71120. Thanks @steipete.

View File

@@ -837,7 +837,7 @@ describe("update-cli", () => {
);
});
it("skips package-manager updates when the installed version already matches the target", async () => {
it("refreshes package-manager updates when the installed version already matches the target", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
readPackageVersion.mockResolvedValue("2026.4.22");
@@ -853,15 +853,11 @@ describe("update-cli", () => {
.mock.calls.filter(
([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g",
);
expect(installCalls).toEqual([]);
expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(installCalls).toHaveLength(1);
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(0);
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
expect(logs.join("\n")).toContain("already-current");
expect(logs.join("\n")).not.toContain("already-current");
});
it("blocks package updates when the target requires a newer Node runtime", async () => {
@@ -1042,6 +1038,78 @@ describe("update-cli", () => {
);
});
it("refreshes package installs even when the current version already matches the target", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-current-"));
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
const entryPath = path.join(pkgRoot, "dist", "index.js");
mockPackageInstallStatus(pkgRoot);
readPackageVersion.mockResolvedValue("2026.4.23");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.23",
});
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.23" }),
"utf-8",
);
await fs.writeFile(entryPath, "export {};\n", "utf-8");
for (const relativePath of TEST_BUNDLED_RUNTIME_SIDECAR_PATHS) {
const absolutePath = path.join(pkgRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf-8");
}
await writePackageDistInventory(pkgRoot);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true, restart: false });
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"],
expect.any(Object),
);
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(
vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n"),
).not.toContain("already-current");
});
it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const brewPrefix = createCaseDir("brew-prefix");

View File

@@ -1112,19 +1112,19 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} else if (updateInstallKind === "git") {
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
} else if (packageAlreadyCurrent) {
actions.push(`Skip package update; current version already matches ${targetVersion}`);
actions.push(
`Refresh package install with spec ${packageInstallSpec ?? tag}; current version already matches ${targetVersion}`,
);
} else {
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`);
}
if (!packageAlreadyCurrent) {
actions.push("Run plugin update sync after core update");
actions.push("Refresh shell completion cache (if needed)");
actions.push(
shouldRestart
? "Restart gateway service and run doctor checks"
: "Skip restart (because --no-restart is set)",
);
}
actions.push("Run plugin update sync after core update");
actions.push("Refresh shell completion cache (if needed)");
actions.push(
shouldRestart
? "Restart gateway service and run doctor checks"
: "Skip restart (because --no-restart is set)",
);
const notes: string[] = [];
if (opts.tag && updateInstallKind === "git") {
@@ -1195,25 +1195,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
);
}
if (packageAlreadyCurrent) {
const mode = isPackageManagerUpdateMode(updateStatus.packageManager)
? updateStatus.packageManager
: "unknown";
const result: UpdateRunResult = {
status: "skipped",
mode,
root,
reason: "already-current",
before: { version: currentVersion },
after: { version: currentVersion },
steps: [],
durationMs: 0,
};
printResult(result, opts);
defaultRuntime.exit(0);
return;
}
if (updateInstallKind === "package") {
const runtimePreflightError = await resolvePackageRuntimePreflightError({
tag,

View File

@@ -87,6 +87,16 @@ describe("package dist inventory", () => {
"left-pad",
"index.js",
);
const omittedRuntimeDepsTempSymlink = path.join(
packageRoot,
"dist",
"extensions",
"amazon-bedrock",
".openclaw-runtime-deps-copy-KZmXaz",
"node_modules",
".bin",
"fxparser",
);
const omittedExtensionNodeModuleSymlink = path.join(
packageRoot,
"dist",
@@ -111,6 +121,7 @@ describe("package dist inventory", () => {
await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true });
await fs.mkdir(path.dirname(omittedRuntimeDepsStamp), { recursive: true });
await fs.mkdir(path.dirname(omittedRuntimeDepsTempFile), { recursive: true });
await fs.mkdir(path.dirname(omittedRuntimeDepsTempSymlink), { recursive: true });
await fs.mkdir(path.dirname(omittedExtensionNodeModuleSymlink), { recursive: true });
await fs.mkdir(path.dirname(omittedExtensionRootAliasSymlink), { recursive: true });
await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true });
@@ -125,6 +136,7 @@ describe("package dist inventory", () => {
await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8");
await fs.writeFile(omittedRuntimeDepsStamp, "{}\n", "utf8");
await fs.writeFile(omittedRuntimeDepsTempFile, "module.exports = 1;\n", "utf8");
await fs.symlink(path.join(packageRoot, "color-support.js"), omittedRuntimeDepsTempSymlink);
await fs.symlink(
path.join(packageRoot, "color-support.js"),
omittedExtensionNodeModuleSymlink,

View File

@@ -60,13 +60,19 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
"acpx@0.5.3",
]);
expect(
createBundledRuntimeDepsInstallEnv({
PATH: "/usr/bin:/bin",
npm_config_global: "true",
npm_config_prefix: "/opt/homebrew",
}),
createBundledRuntimeDepsInstallEnv(
{
PATH: "/usr/bin:/bin",
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
npm_config_cache: "/Users/alice/.npm",
npm_config_global: "true",
npm_config_prefix: "/opt/homebrew",
},
{ cacheDir: "/opt/openclaw/runtime-cache" },
),
).toEqual({
PATH: "/usr/bin:/bin",
npm_config_cache: "/opt/openclaw/runtime-cache",
npm_config_legacy_peer_deps: "true",
npm_config_package_lock: "false",
npm_config_save: "false",
@@ -262,6 +268,49 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("uses an OpenClaw-owned npm cache for runtime dependency installs", () => {
const installRoot = makeTempDir();
spawnSyncMock.mockReturnValue({
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
});
installBundledRuntimeDeps({
installRoot,
missingSpecs: ["tokenjuice@0.6.1"],
env: {
HOME: "/Users/alice",
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
npm_config_cache: "/Users/alice/.npm",
},
});
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
cwd: installRoot,
env: expect.objectContaining({
HOME: "/Users/alice",
npm_config_cache: path.join(installRoot, ".openclaw-npm-cache"),
}),
}),
);
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
env: expect.not.objectContaining({
NPM_CONFIG_CACHE: expect.any(String),
}),
}),
);
});
it("cleans an owned isolated execution root after copying node_modules back", () => {
const installRoot = makeTempDir();
const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage");

View File

@@ -566,18 +566,24 @@ function storeSourceCheckoutRuntimeDepsCache(params: {
function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const nextEnv = { ...env };
delete nextEnv.NPM_CONFIG_CACHE;
delete nextEnv.npm_config_cache;
delete nextEnv.npm_config_global;
delete nextEnv.npm_config_location;
delete nextEnv.npm_config_prefix;
return nextEnv;
}
export function createBundledRuntimeDepsInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function createBundledRuntimeDepsInstallEnv(
env: NodeJS.ProcessEnv,
options: { cacheDir?: string } = {},
): NodeJS.ProcessEnv {
return {
...createNestedNpmInstallEnv(env),
npm_config_legacy_peer_deps: "true",
npm_config_package_lock: "false",
npm_config_save: "false",
...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}),
};
}
@@ -989,7 +995,9 @@ export function installBundledRuntimeDeps(params: {
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const installEnv = createBundledRuntimeDepsInstallEnv(params.env, {
cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"),
});
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),