mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
fix(plugins): harden runtime dependency repair
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user