diff --git a/CHANGELOG.md b/CHANGELOG.md index 1affb4ae1be..fba7c047b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai. - Active Memory: apply `setupGraceTimeoutMs` to the embedded recall runner as well as the outer prompt-build watchdog, so very-cold first recalls keep the configured setup grace end-to-end. (#74480) Thanks @volcano303. - CLI/config: keep JSON dry-run patches validating touched channel configuration against bundled channel schemas even when the patch only contains SecretRef objects. - Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art. diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 525257e1394..edaf8b6147e 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -265,6 +265,14 @@ describe("runGatewayUpdate", () => { } } + async function writeGatewayEntrypoint(pkgRoot: string) { + const entrypoint = path.join(pkgRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(entrypoint), { recursive: true }); + await fs.writeFile(entrypoint, "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + return entrypoint; + } + async function createGlobalPackageFixture(rootDir: string) { const nodeModules = path.join(rootDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); @@ -1456,6 +1464,87 @@ describe("runGatewayUpdate", () => { ); }); + it("runs doctor after global npm updates before reporting success", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + await seedGlobalPackageRoot(pkgRoot); + + let doctorEnv: NodeJS.ProcessEnv | undefined; + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + onInstall: async () => { + await writeGlobalPackageVersion(pkgRoot); + await writeGatewayEntrypoint(pkgRoot); + }, + }); + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join( + pkgRoot, + "dist", + "index.js", + )} doctor --non-interactive --fix`; + const runCommandWithDoctor = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => { + const key = argv.join(" "); + if (key === doctorCommand) { + calls.push(key); + doctorEnv = options?.env; + return { stdout: "doctor repaired config", stderr: "", code: 0 }; + } + return runCommand(argv, options); + }; + + const result = await runWithCommand(runCommandWithDoctor, { cwd: pkgRoot }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(doctorCommand); + expect(result.steps.map((step) => step.name)).toContain("openclaw doctor"); + expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); + expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1"); + }); + + it("fails global npm updates when post-update doctor fails", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + await seedGlobalPackageRoot(pkgRoot); + + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + onInstall: async () => { + await writeGlobalPackageVersion(pkgRoot); + await writeGatewayEntrypoint(pkgRoot); + }, + }); + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join( + pkgRoot, + "dist", + "index.js", + )} doctor --non-interactive --fix`; + const runCommandWithDoctor = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => { + const key = argv.join(" "); + if (key === doctorCommand) { + calls.push(key); + return { stdout: "", stderr: "doctor refused migration", code: 1 }; + } + return runCommand(argv, options); + }; + + const result = await runWithCommand(runCommandWithDoctor, { cwd: pkgRoot }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("doctor-failed"); + expect(calls).toContain(doctorCommand); + expect(result.steps.at(-1)).toMatchObject({ + name: "openclaw doctor", + exitCode: 1, + stderrTail: "doctor refused migration", + }); + }); + it("falls back to global npm update when git is missing from PATH", async () => { const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); const { calls, runCommand } = createGlobalInstallHarness({ diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 7d30f7cae9e..7e0f890d688 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { resolveControlUiDistIndexHealth, @@ -1443,6 +1444,27 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< stepIndex: 0, totalSteps: 1, }), + postVerifyStep: async (verifiedPackageRoot) => { + const doctorEntry = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); + if (!doctorEntry) { + return null; + } + const doctorNodePath = await resolveStableNodePath(process.execPath); + return await runStep({ + runCommand, + name: "openclaw doctor", + argv: [doctorNodePath, doctorEntry, "doctor", "--non-interactive", "--fix"], + cwd: verifiedPackageRoot, + timeoutMs, + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + }, + progress, + stepIndex: 0, + totalSteps: 1, + }); + }, }); return { status: packageUpdate.failedStep ? "error" : "ok",