import { writeFileSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { createConfigIO } from "../../config/io.js"; import { consumeUpdatePostInstallDoctorResult, createDeferredConfiguredPluginRepairDoctorResult, UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV, writeUpdatePostInstallDoctorResult, } from "../../infra/update-doctor-result.js"; import { createUpdateRun, recordUpdateRunStep } from "../../infra/update-run-ledger.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { removePreparedWorkerOwnershipColumns } from "../../state/openclaw-state-schema-v17.test-support.js"; import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; const mocks = vi.hoisted(() => ({ readConfig: vi.fn(), resolveEntrypoint: vi.fn(), runExec: vi.fn(), })); vi.mock("../../config/config.js", async (importOriginal) => ({ ...(await importOriginal()), readConfigFileSnapshot: mocks.readConfig, })); vi.mock("../../daemon/gateway-entrypoint.js", () => ({ resolveGatewayInstallEntrypoint: mocks.resolveEntrypoint, })); vi.mock("../../process/exec.js", async (importOriginal) => ({ ...(await importOriginal()), runExec: mocks.runExec, })); vi.mock("../../runtime.js", () => ({ defaultRuntime: { error: vi.fn(), log: vi.fn() }, })); vi.mock("./shared.js", async (importOriginal) => ({ ...(await importOriginal()), resolveNodeRunner: vi.fn(() => "/usr/bin/node"), })); import { completePostCorePluginUpdate, runUpdateFinalizationDoctorInFreshProcess, } from "./update-command-fresh-doctor.js"; const pluginUpdate: PostCorePluginUpdateResult = { status: "ok", changed: true, sync: { changed: false, switchedToBundled: [], switchedToNpm: [], warnings: [], errors: [], }, npm: { changed: false, outcomes: [] }, integrityDrifts: [], warnings: [], }; const updateOptions = { root: "/opt/openclaw", pluginUpdate, freshDoctorRequired: true, yes: true, json: true, timeoutMs: 5_000, }; const validConfigSnapshot = { exists: true, valid: true as const, parsed: {}, config: {}, runtimeConfig: {}, sourceConfig: {}, warnings: [], issues: [], legacyIssues: [], }; const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { closeOpenClawStateDatabaseForTest(); vi.unstubAllEnvs(); }); describe("post-plugin update readiness", () => { beforeEach(() => { mocks.readConfig.mockReset().mockResolvedValue(validConfigSnapshot); mocks.resolveEntrypoint.mockReset().mockResolvedValue("/opt/openclaw/dist/index.js"); mocks.runExec.mockReset().mockImplementation(async (_command, args: string[]) => ({ stdout: args.includes("--lint") ? `${JSON.stringify({ ok: true, checksRun: 1, checksSkipped: 0, findings: [] })}\n` : "", stderr: "", })); }); it.each([undefined, 5_000])("propagates the primary Doctor timeout %s", async (timeoutMs) => { await runUpdateFinalizationDoctorInFreshProcess({ ...updateOptions, phase: "pre-plugin", timeoutMs, }); expect(mocks.runExec).toHaveBeenCalledExactlyOnceWith( "/usr/bin/node", expect.arrayContaining(["doctor", "--repair"]), expect.objectContaining({ timeoutMs }), ); }); it.each([undefined, 5_000])( "bounds post-plugin checks separately from Doctor (%s)", async (timeoutMs) => { vi.stubEnv("OPENCLAW_STATE_DIR", tempDirs.make("post-plugin-empty-budget-")); await completePostCorePluginUpdate({ ...updateOptions, timeoutMs, }); expect(mocks.runExec.mock.calls.map(([, args]) => args)).toEqual([ [ "/opt/openclaw/dist/index.js", "doctor", "--repair", "--non-interactive", "--no-workspace-suggestions", "--yes", ], ["/opt/openclaw/dist/index.js", "config", "validate", "--json"], ["/opt/openclaw/dist/index.js", "doctor", "--lint", "--json", "--severity-min", "error"], ]); expect(mocks.runExec.mock.calls[2]?.[2]).toMatchObject({ env: { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" }, }); expect(mocks.runExec.mock.calls.map((call) => call[2].timeoutMs)).toEqual([ timeoutMs, timeoutMs ?? 300_000, timeoutMs ?? 300_000, ]); }, ); it.each([ { name: "shared", shared: true, agent: false, budget: 2_860_000 }, { name: "main agent", shared: false, agent: true, budget: 2_860_000 }, { name: "shared and main agent", shared: true, agent: true, budget: 3_160_000 }, ])("measures migrated $name database families for both post-plugin checks", async (testCase) => { const stateDir = tempDirs.make("post-plugin-budget-"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); const databases = [ ...(testCase.shared ? [resolveOpenClawStateSqlitePath(process.env)] : []), ...(testCase.agent ? [path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite")] : []), ]; const bytes = 1024 ** 3 / databases.length; for (const databasePath of databases) { await fs.mkdir(path.dirname(databasePath), { recursive: true }); for (const file of [databasePath, `${databasePath}-wal`]) { await fs.writeFile(file, ""); } await fs.truncate(databasePath, bytes); } mocks.runExec.mockImplementationOnce(async () => { for (const databasePath of databases) { await fs.truncate(`${databasePath}-wal`, bytes); } return { stdout: "", stderr: "" }; }); const result = await completePostCorePluginUpdate({ ...updateOptions, timeoutMs: undefined }); expect(result.pluginUpdate.status).toBe("ok"); expect(mocks.runExec.mock.calls.map((call) => call[2].timeoutMs)).toEqual([ undefined, testCase.budget, testCase.budget, ]); }); it("budgets configured agent stores without enumerating unrelated agent directories", async () => { const stateDir = tempDirs.make("post-plugin-configured-budget-"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); const agentsDir = path.join(stateDir, "agents"); const databasePath = path.join(agentsDir, "configured", "agent", "openclaw-agent.sqlite"); await fs.mkdir(path.dirname(databasePath), { recursive: true }); await fs.writeFile(databasePath, ""); await fs.truncate(databasePath, 2 * 1024 ** 3); mocks.readConfig.mockResolvedValue({ ...validConfigSnapshot, sourceConfig: { agents: { entries: { configured: {} } } }, }); const readdir = fs.readdir; const enumeration = vi.spyOn(fs, "readdir").mockImplementation((...args) => { if (args[0] === agentsDir) { return Promise.reject( Object.assign(new Error("agent enumeration denied"), { code: "EACCES" }), ); } return readdir(...args); }); try { const result = await completePostCorePluginUpdate({ ...updateOptions, freshDoctorRequired: false, timeoutMs: undefined, }); expect(result.pluginUpdate.status).toBe("ok"); expect(mocks.runExec.mock.calls.map((call) => call[2].timeoutMs)).toEqual([ 2_860_000, 2_860_000, ]); expect(enumeration).not.toHaveBeenCalledWith(agentsDir, { withFileTypes: true }); } finally { enumeration.mockRestore(); } }); it("runs updated readiness checks even when no plugin package changed", async () => { const beforeDoctor = vi.fn(async () => undefined); await completePostCorePluginUpdate({ ...updateOptions, pluginUpdate: { ...pluginUpdate, changed: false }, freshDoctorRequired: false, beforeDoctor, }); expect(beforeDoctor).not.toHaveBeenCalled(); expect(mocks.runExec.mock.calls.map(([, args]) => args)).toEqual([ ["/opt/openclaw/dist/index.js", "config", "validate", "--json"], ["/opt/openclaw/dist/index.js", "doctor", "--lint", "--json", "--severity-min", "error"], ]); }); it("runs recorded deferred retirement when the published driver flag is false", async () => { await withTempHome(async () => { const run = createUpdateRun({ trigger: "cli" }); vi.stubEnv("OPENCLAW_UPDATE_RUN_ID", run.runId); recordUpdateRunStep(run.runId, { step: "finalize:doctor:model-retirement", status: "skipped", detail: "Model retirement repair deferred until plugin convergence.", }); const beforeDoctor = vi.fn(async () => undefined); await completePostCorePluginUpdate({ ...updateOptions, pluginUpdate: { ...pluginUpdate, changed: false }, freshDoctorRequired: false, beforeDoctor, }); expect(beforeDoctor).toHaveBeenCalledOnce(); expect(mocks.runExec.mock.calls[0]?.[1]).toEqual([ "/opt/openclaw/dist/index.js", "doctor", "--repair", "--non-interactive", "--no-workspace-suggestions", "--yes", ]); expect(mocks.runExec.mock.calls[0]?.[2]).toMatchObject({ env: { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" }, }); }); }); it.each([false, true])( "preserves an unconfigured install through finalization (Doctor: %s)", async (freshDoctorRequired) => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const io = createConfigIO({ configPath, observe: false }); mocks.readConfig.mockImplementation(() => io.readConfigFileSnapshot()); const runNormally = mocks.runExec.getMockImplementation()!; mocks.runExec.mockImplementation(async (command, args: string[], options) => { if (args.includes("validate")) { throw new Error("Config file not found"); } return await runNormally(command, args, options); }); const result = await completePostCorePluginUpdate({ ...updateOptions, freshDoctorRequired, }); expect(result.pluginUpdate.status).toBe("ok"); expect(result.configSnapshot).toMatchObject({ exists: false, valid: true }); expect(mocks.runExec.mock.calls.some(([, args]) => args.includes("--lint"))).toBe(true); await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); }); }, ); it("validates a config created during fresh Doctor before allowing restart", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const io = createConfigIO({ configPath, observe: false }); mocks.readConfig.mockImplementation(() => io.readConfigFileSnapshot()); mocks.runExec.mockImplementation(async (_command, args: string[]) => { if (args.includes("--repair")) { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, '{"gateway":{"mode":"invalid"}}'); } if (args.includes("validate")) { throw new Error("Config invalid"); } return { stdout: "", stderr: "" }; }); const result = await completePostCorePluginUpdate(updateOptions); expect(result.configSnapshot).toMatchObject({ exists: true, valid: false }); expect(result.pluginUpdate).toMatchObject({ status: "error", reason: "post-plugin-doctor-invalid-config", }); }); }); it("consumes nonfatal Doctor warnings before reporting successful convergence", async () => { const warnings = ["Optional probe timed out; recheck after restart."]; const onWarnings = vi.fn(); let resultPath = ""; const runNormally = mocks.runExec.getMockImplementation()!; mocks.runExec.mockImplementation(async (command, args: string[], options) => { if (args.includes("--repair")) { resultPath = options.env[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]; await writeUpdatePostInstallDoctorResult({ resultPath, result: { status: "ok", warnings }, }); } return await runNormally(command, args, options); }); const result = await completePostCorePluginUpdate({ ...updateOptions, onWarnings }); expect(result.pluginUpdate.status).toBe("ok"); expect(onWarnings).toHaveBeenCalledExactlyOnceWith(warnings); expect(await consumeUpdatePostInstallDoctorResult(resultPath)).toBeNull(); }); it.each([false, true])( "preserves deferred repair advisory semantics (timed out: %s)", async (timedOut) => { mocks.runExec.mockImplementation(async (_command, _args, options) => { await writeUpdatePostInstallDoctorResult({ resultPath: options.env[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV], result: createDeferredConfiguredPluginRepairDoctorResult(["plugin repair deferred"]), }); throw Object.assign(new Error("Doctor advisory"), { failed: true, exitCode: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, timedOut, }); }); const run = runUpdateFinalizationDoctorInFreshProcess({ ...updateOptions, phase: "pre-plugin", }); if (timedOut) { await expect(run).rejects.toThrow("Doctor advisory"); } else { await expect(run).resolves.toBeUndefined(); } }, ); it("carries the Doctor's failing check through fresh-process convergence", async () => { const failureFacts = [ { check: "state.session-participants", code: "step-refused", message: "Required session migration could not acquire its writer.", }, ]; const runNormally = mocks.runExec.getMockImplementation()!; mocks.runExec.mockImplementation(async (command, args: string[], options) => { if (!args.includes("--repair")) { return await runNormally(command, args, options); } await writeUpdatePostInstallDoctorResult({ resultPath: options.env[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV], result: { status: "error", failureFacts }, }); throw Object.assign(new Error("Doctor exited"), { exitCode: 23, stderr: "Last cleanup message", }); }); await expect( runUpdateFinalizationDoctorInFreshProcess({ ...updateOptions, phase: "pre-plugin", }), ).rejects.toMatchObject({ failureFacts, exitCode: 23 }); const result = await completePostCorePluginUpdate(updateOptions); expect(result.pluginUpdate).toMatchObject({ status: "error", failureFacts }); }); it("requires the lifecycle owner before starting fresh Doctor maintenance", async () => { const beforeDoctor = vi.fn(async () => undefined); await completePostCorePluginUpdate({ ...updateOptions, beforeDoctor, }); expect(beforeDoctor).toHaveBeenCalledOnce(); expect(beforeDoctor.mock.invocationCallOrder[0]).toBeLessThan( mocks.runExec.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); }); it("uses target validation when the unchanged-plugin parent retains an older schema", async () => { mocks.readConfig.mockResolvedValue({ ...validConfigSnapshot, valid: false }); const result = await completePostCorePluginUpdate({ ...updateOptions, pluginUpdate: { ...pluginUpdate, changed: false }, freshDoctorRequired: false, }); expect(result.pluginUpdate.status).toBe("ok"); expect(result.configSnapshot.valid).toBe(false); expect(mocks.runExec.mock.calls.map(([, args]) => args)).toEqual([ ["/opt/openclaw/dist/index.js", "config", "validate", "--json"], ["/opt/openclaw/dist/index.js", "doctor", "--lint", "--json", "--severity-min", "error"], ]); }); it("preserves the older target database when reading post-update config context", async () => { const stateDir = tempDirs.make("openclaw-post-update-target-schema-"); const configPath = path.join(stateDir, "openclaw.json"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); writeFileSync(configPath, JSON.stringify({ gateway: { mode: "local" } })); const filename = openOpenClawStateDatabase({ env: process.env }).path; closeOpenClawStateDatabaseForTest(); const db = new DatabaseSync(filename); try { removePreparedWorkerOwnershipColumns(db); db.exec( "PRAGMA user_version=16; UPDATE schema_meta SET schema_version=16, app_version='2026.9.2'", ); const beforeSchema = db.prepare("SELECT * FROM sqlite_schema ORDER BY name").all(); const beforeMeta = db.prepare("SELECT * FROM schema_meta").all(); const configOwner = await vi.importActual("../../config/config.js"); mocks.readConfig.mockImplementation(configOwner.readConfigFileSnapshot); const result = await completePostCorePluginUpdate({ ...updateOptions, pluginUpdate: { ...pluginUpdate, changed: false }, freshDoctorRequired: false, }); expect(result.pluginUpdate.status).toBe("ok"); expect(result.configSnapshot.config.gateway?.mode).toBe("local"); expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 16 }); expect(db.prepare("SELECT * FROM schema_meta").all()).toEqual(beforeMeta); expect(db.prepare("SELECT * FROM sqlite_schema ORDER BY name").all()).toEqual(beforeSchema); } finally { db.close(); } }); it("does not start Doctor when the lifecycle owner refuses maintenance", async () => { const beforeDoctor = vi.fn(async () => { throw new Error("Gateway owner changed"); }); const result = await completePostCorePluginUpdate({ ...updateOptions, beforeDoctor, }); expect(beforeDoctor).toHaveBeenCalledOnce(); expect(mocks.runExec.mock.calls.some(([, args]) => args.includes("--repair"))).toBe(false); expect(result.pluginUpdate).toMatchObject({ status: "error", warnings: [ expect.objectContaining({ reason: expect.stringContaining("Gateway owner changed") }), ], }); }); it.each([true, false])("preserves readiness failures (config exists: %s)", async (exists) => { mocks.readConfig.mockResolvedValue({ ...validConfigSnapshot, exists }); mocks.runExec.mockImplementation(async (_command, args: string[]) => { if (args.includes("--lint")) { throw Object.assign(new Error("readiness failed"), { exitCode: 1, stdout: `${JSON.stringify({ ok: false, checksRun: 1, checksSkipped: 0, findings: [ { checkId: "memory-core/managed-local-embedding-setup", severity: "error", source: "memory-core", message: "Managed local embeddings are unavailable.", fixHint: "Run `openclaw models --agent main auth login --provider llama-cpp --method local`.", }, ], })}\n`, stderr: "", }); } return { stdout: "", stderr: "" }; }); const result = await completePostCorePluginUpdate({ ...updateOptions, }); expect(result.pluginUpdate).toMatchObject({ status: "error", reason: "post-plugin-update-readiness-failed", warnings: [ { pluginId: "memory-core", reason: "memory-core/managed-local-embedding-setup", message: "Managed local embeddings are unavailable.", guidance: [ "Run `openclaw models --agent main auth login --provider llama-cpp --method local`.", ], }, ], }); }); it("retains posture warnings while accepting post-plugin readiness", async () => { mocks.runExec.mockImplementation(async (_command, args: string[]) => ({ stdout: args.includes("--lint") ? JSON.stringify({ ok: true, checksRun: 1, findings: [], warnings: [ { checkId: "core/doctor/security", severity: "warning", message: "Open group policy permits mention-gated requests.", fixHint: "Review the group allowlist.", }, ], }) : "", stderr: "", })); const result = await completePostCorePluginUpdate(updateOptions); expect(result.pluginUpdate).toMatchObject({ status: "warning", warnings: [ { reason: "doctor-advisory", message: "Open group policy permits mention-gated requests.", guidance: ["Review the group allowlist."], }, ], }); }); it.each([ { label: "malformed output", stdout: "{not-json\n", }, { label: "no declared check", stdout: `${JSON.stringify({ ok: true, checksRun: 0, checksSkipped: 0, findings: [] })}\n`, }, ])("fails closed on $label from the updated readiness child", async ({ stdout }) => { mocks.runExec.mockImplementation(async (_command, args: string[]) => ({ stdout: args.includes("--lint") ? stdout : "", stderr: "", })); const result = await completePostCorePluginUpdate({ ...updateOptions, }); expect(result.pluginUpdate).toMatchObject({ status: "error", reason: "post-plugin-update-readiness-execution-failed", warnings: [ expect.objectContaining({ message: "Updated plugin readiness checks could not be completed before restart.", }), ], }); }); });