// Install the fixture mocks before loading the execution owner and its dependencies. import "./update-command-execution.test-support.js"; import { once } from "node:events"; import fs from "node:fs/promises"; import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import * as configFile from "../../config/config.js"; import * as gatewayService from "../../daemon/service.js"; import { mockSystemAccountHome } from "../../daemon/service.test-helpers.js"; import * as gatewayCall from "../../gateway/call.js"; import { gatewayHealthResponse } from "../../gateway/health-response.test-support.js"; import * as portInspection from "../../infra/ports-inspect.js"; import * as tempRoot from "../../infra/tmp-openclaw-dir.js"; import { UpdateRequesterRevokedError } from "../../infra/update-requester-authority.js"; import { createUpdateRun } from "../../infra/update-run-ledger.js"; import { updateRunStepsFromResultStep, updateRunWarningMessages, } from "../../infra/update-run-step.js"; import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js"; import { withTestDir } from "../../test-helpers/temp-dir.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { mockProcessPlatform } from "../../test-utils/vitest-spies.js"; import * as utils from "../../utils.js"; import * as restartProbe from "../daemon-cli/restart-health-probe.js"; import { UpdatePreMutationError } from "./shared.js"; import { executeMutableUpdate } from "./update-command-execution.js"; import { withUpdateCommandExecutor } from "./update-command-executor.js"; import { gatewayServiceCommandUsesRoot, GatewayServiceUpdateOwnershipError, } from "./update-command-service-plan.js"; const { executionParams, inspectOrStopService, mocks, schemaContext, successfulUpdate } = await import("./update-command-execution.test-support.js"); describe("mutable update execution", () => { it.each(["package", "git"] as const)( "continues the %s update with the recorded readiness warning instead of inference repair", async (kind) => { const message = "Readiness probe http://127.0.0.1:18789/readyz failed: HTTP 502. Check the configured proxy."; const step: UpdateStepResult = { name: "candidate gateway canary", command: "gateway run", cwd: "/candidate", durationMs: 1, exitCode: null, advisory: { kind: "candidate-runtime-unavailable", message }, failureFacts: [{ check: "readyz", code: "candidate-readiness-probe-failed", message }], }; mocks.validateCanary.mockImplementation(async ({ onStep }) => { onStep(step); return { status: "ok", phase: "readiness", steps: [step], durationMs: 1, logTail: [message], }; }); const repair = await import("./update-command-repair.js"); const runRepair = vi.spyOn(repair, "runUpdateCommandRepair"); const accepted = vi.fn(); const runStagedUpdate = async ({ validateCandidate, }: { validateCandidate?: (root: string) => Promise; }) => { expect(validateCandidate).toBeTypeOf("function"); await validateCandidate?.("/candidate"); accepted(); return successfulUpdate; }; mocks.runPackageUpdate.mockImplementation(runStagedUpdate); mocks.runGitUpdate.mockImplementation(runStagedUpdate); const onStepComplete = vi.fn>(); const execution = await executeMutableUpdate({ ...executionParams(kind), progress: { onStepComplete }, }); expect(execution?.result.status).toBe("ok"); expect(accepted).toHaveBeenCalledOnce(); expect(runRepair).not.toHaveBeenCalled(); expect(onStepComplete).toHaveBeenCalledWith(expect.objectContaining(step)); const recorded = onStepComplete.mock.calls.flatMap(([completed]) => updateRunStepsFromResultStep(completed), ); expect(updateRunWarningMessages(recorded)).toEqual([message]); expect(recorded.every((entry) => entry.status === "completed")).toBe(true); }, ); it.each( (["package", "git"] as const).flatMap((kind) => [undefined, 30_000, 600_000].map((timeoutMs) => ({ kind, timeoutMs })), ), )( "passes only the operator's $timeoutMs ms deadline to $kind candidate validation", async ({ kind, timeoutMs }) => { const runStagedUpdate = async ({ validateCandidate, }: { validateCandidate?: (root: string) => Promise; }) => { expect(validateCandidate).toBeTypeOf("function"); await validateCandidate?.("/candidate"); return successfulUpdate; }; mocks.runPackageUpdate.mockImplementation(runStagedUpdate); mocks.runGitUpdate.mockImplementation(runStagedUpdate); const execution = await executeMutableUpdate({ ...executionParams(kind), timeoutMs, updateStepTimeoutMs: timeoutMs ?? 30 * 60_000, }); expect(execution?.result.status).toBe("ok"); expect(mocks.validateCanary).toHaveBeenCalledOnce(); expect(mocks.validateCanary.mock.calls[0]?.[0].root).toBe("/candidate"); expect(mocks.validateCanary.mock.calls[0]?.[0].timeoutMs).toBe(timeoutMs); }, ); it.each([ ["measured startup", undefined, true, undefined], ["explicit allowance", 450_000, true, undefined], ["explicit deadline", 30_000, false, undefined], ["terminal version mismatch", undefined, false, "version"], ["replaced executor", undefined, false, "executor"], ] as const)( "preserves previous Gateway verification through slow readiness (%s)", async (_allowance, timeoutMs, verified, failure) => withTestDir({ prefix: "previous-gateway-readiness-" }, async (root) => { const readyAtMs = 400_000; mockProcessPlatform("linux"); let elapsedMs = 0; const epochMs = Date.now(); vi.spyOn(performance, "now").mockImplementation(() => elapsedMs); vi.spyOn(Date, "now").mockImplementation(() => epochMs + elapsedMs); vi.spyOn(utils, "sleep").mockImplementation(async (delayMs) => { elapsedMs += delayMs; }); let readyObservedAtMs: number | undefined; let stoppedAtMs: number | undefined; let replaceExecutor: (() => void) | undefined; const server = createServer((request, response) => { const ready = elapsedMs >= readyAtMs; if (request.url === "/readyz" && ready) { readyObservedAtMs = elapsedMs; replaceExecutor?.(); } response.writeHead(request.url === "/readyz" && !ready ? 503 : 200).end(); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Missing synthetic Gateway listener"); } try { await fs.mkdir(path.join(root, "dist")); await fs.writeFile( path.join(root, "package.json"), JSON.stringify({ name: "openclaw", version: "1.0.0" }), ); await fs.writeFile(path.join(root, "dist", "index.js"), ""); const context = schemaContext("default"); const config = { gateway: { mode: "local" as const, port: address.port } }; const configSnapshot = { ...context.configSnapshot, config, sourceConfig: config, }; const managedEnv = { HOME: root, OPENCLAW_STATE_DIR: path.join(root, ".openclaw") }; vi.spyOn(os, "userInfo").mockReturnValue({ uid: 1000, gid: 1000, username: "operator", homedir: root, shell: "/bin/sh", }); mocks.captureManagedContext.mockResolvedValue({ env: managedEnv, configSnapshot, pluginInstallRecords: {}, }); vi.spyOn(configFile, "readConfigFileSnapshot").mockResolvedValue(configSnapshot); vi.spyOn(restartProbe, "resolveGatewayRestartProbeContext").mockResolvedValue({ config, auth: {}, }); const service = gatewayService.resolveGatewayService(); vi.spyOn(gatewayService, "resolveGatewayService").mockReturnValue(service); vi.spyOn(service, "readRuntime").mockResolvedValue({ status: "running", pid: 8000 }); vi.spyOn(service, "readCommand").mockResolvedValue({ programArguments: [process.execPath, path.join(root, "dist", "index.js"), "gateway"], }); expect(await gatewayServiceCommandUsesRoot({ root, env: managedEnv })).toBe(true); vi.spyOn(portInspection, "inspectPortUsage").mockImplementation(async (port) => ({ port, status: "busy", listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }], hints: [], })); vi.spyOn(gatewayCall, "callGateway").mockImplementation( gatewayHealthResponse({ server: { version: failure === "version" ? "0.9.0" : "1.0.0", bootId: "previous-boot", }, }), ); mocks.validateCanary.mockResolvedValue({ status: "ok", phase: "readiness", durationMs: 70_000, logTail: [], steps: [ { name: "candidate gateway canary", command: "gateway run", cwd: root, durationMs: 70_000, exitCode: 0, }, ], }); mocks.maybeStopService.mockImplementation(async ({ phase }) => { if (phase === "prepare") { stoppedAtMs = elapsedMs; } return inspectOrStopService(phase); }); mocks.runPackageUpdate.mockImplementation( async ( params: Parameters< typeof import("./update-command-package.js").runPackageInstallUpdate >[0], ) => { await params.validateCandidate(root); await params.beforeActivate(); return successfulUpdate; }, ); const params = { ...executionParams("package"), root, timeoutMs, updateStepTimeoutMs: timeoutMs ?? 20 * 60_000, }; if (failure === "executor") { replaceExecutor = () => { params.opts.run = { runId: "replacement-run", env: { OPENCLAW_STATE_DIR: root } }; }; } const execution = await executeMutableUpdate(params); if (failure === "executor") { expect(execution?.result.status).toBe("error"); expect(execution?.failure?.detail).toContain("lost its original executor"); expect(stoppedAtMs).toBeUndefined(); expect(execution?.previousVerified).toBe(false); return; } expect(execution?.result.status, JSON.stringify(mocks.runtimeError.mock.calls)).toBe( "ok", ); expect( execution?.previousVerified, JSON.stringify({ readyObservedAtMs, stoppedAtMs }), ).toBe(verified); if (verified) { expect(readyObservedAtMs).toBeGreaterThanOrEqual(readyAtMs); expect(stoppedAtMs).toBeGreaterThanOrEqual(readyObservedAtMs!); } else { expect(readyObservedAtMs).toBeUndefined(); expect(stoppedAtMs).toBeLessThan(readyAtMs); if (failure === "version") { expect(stoppedAtMs).toBe(0); } } } finally { server.closeAllConnections(); const closed = once(server, "close"); server.close(); await closed; } }), ); it("retains the live update run when stopped-service context capture fails", async () => { await withTestDir({ prefix: "partial-stop-recovery-owner-" }, async (dir) => { const control = path.join(dir, "leases"); await fs.mkdir(control); vi.spyOn(tempRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(control); const env = { OPENCLAW_STATE_DIR: dir }; const runId = createUpdateRun({ trigger: "cli" }, { env }).runId; const params = executionParams("package"); params.root = dir; params.opts.run = { runId, env }; mocks.maybeStopService.mockImplementation(async () => ({ ...inspectOrStopService("prepare"), serviceEnv: env, serviceUpdateVerdict: { kind: "owned", root: dir, fingerprint: "original", refreshDefinition: false, }, })); mocks.captureManagedContext.mockRejectedValueOnce( new Error("fixture config became unreadable"), ); let recoveryRun: typeof params.opts.run; mocks.maybeRestartService.mockImplementation(async (request) => { recoveryRun = request.updateRun; recoveryRun?.executorFence?.assertCurrent(); return "healthy"; }); await withUpdateCommandExecutor(runId, async (executor) => { params.opts.run!.executorFence = await executor.enter(dir, { preflight: true }); const result = await executeMutableUpdate(params); expect(result?.result.status).toBe("error"); expect(mocks.maybeRestartService).toHaveBeenCalledOnce(); expect(recoveryRun).toBe(params.opts.run); expect(mocks.serviceStopped).toBe(true); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); }); }); }); it("refuses service admission before mutable startup housekeeping", async () => { mocks.maybeStopService.mockImplementation(async ({ phase, handoffFromGateway }) => { if (handoffFromGateway) { throw new UpdatePreMutationError("managed-service-preflight", "service owner changed"); } return inspectOrStopService(phase); }); const execution = await executeMutableUpdate(executionParams("package")); expect(execution).toMatchObject({ mutationStarted: false, result: { status: "error", reason: "managed-service-preflight" }, }); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); expect(mocks.serviceStopped).toBe(false); }); it.each( (["package", "git"] as const).flatMap((kind) => [false, true].map((shouldRestart) => ({ kind, shouldRestart })), ), )( "explains FreeBSD $kind refusal before mutation with restart=$shouldRestart", async ({ kind, shouldRestart }) => withEnvAsync({ OPENCLAW_SUPERVISOR_MODE: undefined }, async () => { mockProcessPlatform("freebsd"); mockSystemAccountHome(); const maintenance = await vi.importActual< typeof import("./update-command-service-maintenance.js") >("./update-command-service-maintenance.js"); mocks.maybeStopService.mockImplementation( maintenance.maybeStopManagedServiceBeforeMutableUpdate, ); const execution = await executeMutableUpdate({ ...executionParams(kind), shouldRestart, opts: { json: true, restart: shouldRestart }, }); expect(execution).toMatchObject({ mutationStarted: false, result: { status: "error", reason: "managed-service-preflight" }, }); expect(execution?.failure?.detail).toContain( "Gateway service inspection is not supported by this CLI on FreeBSD", ); expect(execution?.failure?.detail).toContain("service-owned state directories"); expect(execution?.failure?.detail).toContain( "For updates, use the original package manager or installer", ); expect(execution?.failure?.detail).toContain("keep pkg-owned files under pkg management"); expect(execution?.failure?.detail).not.toContain("gateway status"); expect(mocks.captureSchemaContext).not.toHaveBeenCalled(); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); expect(mocks.runGitUpdate).not.toHaveBeenCalled(); }), ); it.each(["admission", "execution"] as const)( "preserves native inspection reasons through %s refusal", async (phase) => { mocks.maybeStopService.mockImplementation(async ({ handoffFromGateway }) => { if (phase === "admission" || handoffFromGateway) { return { stopped: false, inspected: false, runtimeInspected: false, running: false, serviceMutationAllowed: false, serviceUpdateVerdict: { kind: "unavailable", message: "The systemd user session bus is unavailable.", inspectionReason: "systemd-user-bus-unavailable", }, blockMessage: "The systemd user session bus is unavailable.", }; } return inspectOrStopService("inspect"); }); const execution = await executeMutableUpdate(executionParams("package")); expect(execution?.result).toMatchObject({ status: "error", reason: "managed-service-preflight", steps: [ { failureFacts: [ { check: "managed-service", code: "systemd-user-bus-unavailable", message: "The systemd user session bus is unavailable.", }, ], }, ], }); expect(mocks.serviceStopped).toBe(false); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); }, ); it.each(["available", "incompatible", "changed-owner"] as const)( "admits local artifacts from the staged version before rehearsal: %s", async (outcome) => { await withTestDir({ prefix: "openclaw-staged-plugin-admission-" }, async (stage) => { await fs.writeFile( path.join(stage, "package.json"), JSON.stringify({ name: "openclaw", version: "1.0.7" }), ); const events: string[] = []; mocks.pluginPreflight.mockImplementation(async ({ targetVersion }) => { events.push("preflight"); expect(targetVersion).toBe("1.0.7"); expect(mocks.serviceStopped).toBe(false); if (outcome === "incompatible") { return [ { pluginId: "fixture", reason: "Installed plugin is incompatible and its replacement is unavailable.", message: "Fixture plugin update needs a retry.", guidance: [], }, ]; } return []; }); mocks.revalidateSchemaContext.mockImplementation(async (context) => { if (outcome === "changed-owner" && events.includes("preflight")) { throw new UpdatePreMutationError("database-schema-preflight", "fixture owner changed"); } return context; }); mocks.validateCanary.mockImplementation(async () => { events.push("rehearsal"); return { status: "ok", phase: "readiness", steps: [], durationMs: 1, logTail: [] }; }); mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate }) => { events.push("staged"); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); try { await validateCandidate(stage); return successfulUpdate; } catch (error) { if (!(error instanceof UpdatePreMutationError)) { throw error; } return { ...successfulUpdate, status: "error", reason: "package-update-failed" }; } }); const execution = await executeMutableUpdate({ ...executionParams("package"), tag: "/tmp/candidate.tgz", packageInstallSpec: "/tmp/candidate.tgz", packageTargetVersion: undefined, }); expect(events).toEqual( outcome === "changed-owner" ? ["staged", "preflight"] : ["staged", "preflight", "rehearsal"], ); expect(execution?.mutationStarted).toBe(false); expect(mocks.serviceStopped).toBe(false); expect(execution?.result.status).toBe(outcome === "changed-owner" ? "error" : "ok"); if (outcome === "changed-owner") { expect(mocks.validateCanary).not.toHaveBeenCalled(); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(execution?.result.reason).toBe("database-schema-preflight"); } }); }, ); it.each(["registry", "artifact", "artifact-state-change"] as const)( "refuses incompatible staged %s schemas before candidate rehearsal or activation", async (target) => { await withTestDir({ prefix: "openclaw-staged-schema-admission-" }, async (stage) => { await fs.writeFile( path.join(stage, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.7.1", openclaw: { schemaVersions: { state: 1, agent: 1 } }, }), ); let databaseAdvanced = target !== "artifact-state-change"; mocks.pluginPreflight.mockImplementation(async () => { databaseAdvanced = true; return []; }); mocks.checkTargetSchemas.mockImplementation(async (versions) => ({ incompatible: versions?.state === 1 && databaseAdvanced ? [ { kind: "state", path: "/fixture/default/state.sqlite", foundVersion: 17, supportedVersion: 1, }, ] : [], indeterminate: [], })); mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate, beforeActivate }) => { await validateCandidate(stage); await beforeActivate(); return successfulUpdate; }); const params = executionParams("package"); if (target !== "registry") { params.tag = "/tmp/candidate.tgz"; params.packageInstallSpec = "/tmp/candidate.tgz"; params.packageTargetVersion = undefined; params.packageTargetSchemaVersions = undefined; } const execution = await executeMutableUpdate(params); expect(mocks.validateCanary.mock.calls.length).toBe(0); expect(execution).toMatchObject({ mutationStarted: false, result: { status: "error", reason: "database-schema-preflight" }, }); expect(mocks.serviceStopped).toBe(false); if (target !== "registry") { expect(mocks.pluginPreflight).toHaveBeenCalledTimes( target === "artifact-state-change" ? 1 : 0, ); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); } }); }, ); it.each([ { metadata: "missing", openclaw: undefined }, { metadata: "malformed", openclaw: { schemaVersions: { state: "15", agent: 19 } } }, ])( "retains registry schema admission when staged metadata is $metadata", async ({ openclaw }) => { await withTestDir({ prefix: "openclaw-staged-schema-retention-" }, async (stage) => { await fs.writeFile( path.join(stage, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.2", openclaw }), ); let databaseAdvanced = false; mocks.checkTargetSchemas.mockImplementation(async (versions) => ({ incompatible: databaseAdvanced && versions?.state === 15 ? [ { kind: "state", path: "/fixture/default/state.sqlite", foundVersion: 17, supportedVersion: 15, }, ] : [], indeterminate: [], })); mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate, beforeActivate }) => { databaseAdvanced = true; await validateCandidate(stage); await beforeActivate(); return successfulUpdate; }); const execution = await executeMutableUpdate({ ...executionParams("package"), tag: "2026.9.2", packageInstallSpec: "openclaw@2026.9.2", packageTargetVersion: "2026.9.2", }); expect(mocks.validateCanary.mock.calls.length).toBe(0); expect(execution).toMatchObject({ mutationStarted: false, result: { status: "error", reason: "database-schema-preflight" }, }); expect(mocks.serviceStopped).toBe(false); }); }, ); it("leaves a staged local same-version no-op free of plugin or mutable preparation", async () => { mocks.runPackageUpdate.mockResolvedValue({ ...successfulUpdate, status: "skipped", reason: "already-current", }); const execution = await executeMutableUpdate({ ...executionParams("package"), tag: "/tmp/candidate.tgz", packageInstallSpec: "/tmp/candidate.tgz", packageTargetVersion: undefined, }); expect(execution?.result.reason).toBe("already-current"); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.pluginPreflight).not.toHaveBeenCalled(); expect(mocks.serviceStopped).toBe(false); }); it.each([ { failure: "missing", contract: "api", range: ">=1.0.0", incompatible: false }, { failure: "metadata", contract: "api", range: ">=1.0.0", incompatible: false }, { failure: "throw", contract: "api", range: ">=1.0.0", incompatible: false }, { failure: "missing", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true }, { failure: "metadata", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true }, { failure: "throw", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true }, { failure: "metadata", contract: "host", range: ">=1.0.2", incompatible: true }, { failure: "throw", contract: "host", range: ">=1.0.2", incompatible: true }, ])( "preserves plugin admission and exception handling ($failure, $contract, $range)", async ({ failure, contract, range, incompatible }) => { await withTestDir({ prefix: "openclaw-plugin-admission-" }, async (installPath) => { await fs.writeFile( path.join(installPath, "package.json"), JSON.stringify({ name: "@example/demo", version: "1.0.0", openclaw: contract === "api" ? { compat: { pluginApi: range } } : { install: { minHostVersion: range } }, }), ); mocks.pluginRecords.mockResolvedValue({ demo: { source: "npm", spec: "@example/demo@1.0.1", version: "1.0.0", installPath }, }); mocks.pluginTargets.mockResolvedValue([{ pluginId: "demo", spec: "@example/demo@1.0.1" }]); const error = failure === "missing" ? "No matching version found" : "registry connection failed: ECONNRESET"; const metadataFailure = new Error(error); if (failure === "throw") { mocks.npmMetadata.mockRejectedValue(metadataFailure); } else { mocks.npmMetadata.mockResolvedValue({ ok: false, category: failure === "metadata" ? "metadata-env" : undefined, error, }); } const actual = await vi.importActual( "./update-command-plugin-preflight.js", ); mocks.pluginPreflight.mockImplementation(actual.preflightConfiguredNpmPluginTargets); const execution = await executeMutableUpdate(executionParams("package")); const unclassifiedFailure = incompatible && failure === "throw"; expect(execution?.result.status).toBe(unclassifiedFailure ? "error" : "ok"); expect(mocks.npmMetadata).toHaveBeenCalledTimes(incompatible ? 1 : 0); expect(mocks.serviceStopped).toBe(false); if (unclassifiedFailure) { expect(execution?.result.reason).toBe("update-failed"); expect(execution?.failure?.cause).toBe(metadataFailure); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); } else { const warnings = await mocks.pluginPreflight.mock.results[0]?.value; expect(execution?.result.reason).toBeUndefined(); expect(mocks.prepareMutableUpdate).toHaveBeenCalledOnce(); expect(mocks.runPackageUpdate).toHaveBeenCalledOnce(); if (incompatible) { expect(warnings).toEqual([ expect.objectContaining({ pluginId: "demo", reason: expect.stringContaining(range), message: 'Plugin "demo" update availability could not be confirmed; the core update can continue.', guidance: [], }), ]); expect(warnings[0]?.reason).toContain("Installed 1.0.0"); expect(warnings[0]?.reason).toContain("@example/demo@1.0.1"); expect(warnings[0]?.reason).toContain(error); if (failure === "metadata") { expect(warnings[0]?.reason).toContain("registry could not be reached"); } expect(mocks.runtimeError).toHaveBeenCalledWith(warnings[0]?.message); } else { expect(warnings).toEqual([]); } } }); }, ); it("waits for plugin availability before preparing a package update", async () => { const available = createDeferred<[]>(); mocks.pluginPreflight.mockImplementation(() => available.promise); const execution = executeMutableUpdate(executionParams("package")); try { await vi.waitFor(() => expect(mocks.pluginPreflight).toHaveBeenCalledOnce()); expect(mocks.serviceStopped).toBe(false); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); } finally { available.resolve([]); } expect((await execution)?.result).toBe(successfulUpdate); expect(mocks.runPackageUpdate).toHaveBeenCalledOnce(); }); it("refuses configuration drift during plugin admission before mutable preparation", async () => { let configChanged = false; mocks.pluginPreflight.mockImplementation(async () => { configChanged = true; return []; }); mocks.revalidateSchemaContext.mockImplementation(async (context) => { if (configChanged) { throw new UpdatePreMutationError("database-schema-preflight", "Configuration changed"); } return context; }); const execution = await executeMutableUpdate(executionParams("package")); expect(execution?.result.reason).toBe("database-schema-preflight"); expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled(); expect(mocks.serviceStopped).toBe(false); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); }); it("captures the package target and admitted service environment before schema awaits", async () => { const events: string[] = []; mocks.runPackageUpdate.mockImplementation(async () => { events.push("install"); return successfulUpdate; }); const serviceState = inspectOrStopService("inspect"); mocks.maybeStopService.mockImplementation(async ({ phase }) => { if (phase === "prepare") { events.push("stop"); return inspectOrStopService(phase); } return serviceState; }); mocks.prepareMutableUpdate.mockImplementation(async (env) => { expect(env).toEqual({ OPENCLAW_PROFILE: "default" }); events.push("mutable-prepare"); }); const schemaGate = createDeferred(); mocks.checkTargetSchemas.mockImplementation(async (_versions, contexts) => { expect(contexts.map((context) => context.env.OPENCLAW_PROFILE)).toEqual([ "invoker", "default", ]); events.push( events.includes("mutable-prepare") ? "schema-after-inspection" : "schema-before-inspection", ); if (events.includes("mutable-prepare")) { await schemaGate.promise; } return { incompatible: [], indeterminate: [] }; }); const params = executionParams("package"); const pendingExecution = executeMutableUpdate(params); try { await vi.waitFor(() => expect(events).toContain("schema-after-inspection")); expect(events.indexOf("schema-before-inspection")).toBeLessThan( events.indexOf("mutable-prepare"), ); expect(events.at(-1)).toBe("schema-after-inspection"); expect(mocks.serviceStopped).toBe(false); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); params.packageInstallSpec = "openclaw@changed-during-schema-check"; serviceState.serviceEnv = { OPENCLAW_PROFILE: "revalidated" }; } finally { schemaGate.resolve(); await pendingExecution; } const execution = await pendingExecution; expect(events.at(-1)).toBe("install"); expect(mocks.prepareMutableUpdate).toHaveBeenCalledOnce(); expect(execution?.result).toBe(successfulUpdate); expect(mocks.runPackageUpdate).toHaveBeenCalledOnce(); expect(mocks.runPackageUpdate).toHaveBeenCalledWith( expect.objectContaining({ installSpec: "openclaw@1.0.1", managedServiceEnv: { OPENCLAW_PROFILE: "default" }, }), ); }); it.each(["before-prepare", "after-prepare"] as const)( "refuses schema mismatch at %s without invoking the package updater", async (phase) => { mocks.checkTargetSchemas.mockImplementation(async () => ({ incompatible: phase === "before-prepare" || mocks.prepareMutableUpdate.mock.calls.length > 0 ? [ { kind: "agent", path: "/fixture/default/worker.sqlite", foundVersion: 999, supportedVersion: 19, }, ] : [], indeterminate: [], })); const execution = await executeMutableUpdate(executionParams("package")); expect(mocks.serviceStopped).toBe(false); expect(mocks.prepareMutableUpdate).toHaveBeenCalledTimes(phase === "after-prepare" ? 1 : 0); expect(execution?.result.reason).toBe("database-schema-preflight"); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); }, ); it.each(["activation", "requester revocation", "service ownership"])( "reports %s exceptions without retrying a fallback package updater", async (kind) => { const failure = kind === "requester revocation" ? new UpdateRequesterRevokedError() : kind === "service ownership" ? new GatewayServiceUpdateOwnershipError( "Service manager returned EACCES.", undefined, "service-manager-access-denied", ) : new Error("activation failed"); mocks.runPackageUpdate.mockRejectedValue(failure); const execution = await executeMutableUpdate(executionParams("package")); expect(mocks.runPackageUpdate).toHaveBeenCalledOnce(); expect(execution?.failure?.cause).toBe(failure); expect(execution?.result).toMatchObject({ status: "error", reason: kind === "requester revocation" ? "requester-revoked" : "update-failed", recovery: { serviceRestartSafe: false, reason: "runtime-verification-failed" }, steps: [expect.objectContaining({ name: "update", exitCode: 1 })], }); expect(mocks.verifyPackageRecovery).not.toHaveBeenCalled(); if (kind === "service ownership") { expect(execution?.result.steps[0]?.failureFacts).toEqual([ { check: "managed-service", code: "service-manager-access-denied", message: "Service manager returned EACCES.", }, ]); } }, ); it("keeps Git candidate selection online and delegates its later activation", async () => { const events: string[] = []; mocks.maybeStopService.mockImplementation(async ({ phase }) => { if (phase === "prepare") { events.push("stop"); } return inspectOrStopService(phase); }); mocks.prepareMutableUpdate.mockImplementation(async () => { events.push("mutable-prepare"); }); mocks.runGitUpdate.mockImplementation( async (params: Parameters[0]) => { if (!params.inspectGitTarget || !params.beforeGitMutation) { throw new Error("Expected both real Git admission callbacks"); } const target = { schemaVersions: { state: 15, agent: 19 } }; await params.inspectGitTarget(target); events.push("git"); return { ...successfulUpdate, mode: "git" }; }, ); const execution = await executeMutableUpdate(executionParams("git")); expect(events).toEqual(["mutable-prepare", "git"]); expect(mocks.serviceStopped).toBe(false); expect(execution?.result.mode).toBe("git"); expect(mocks.runPackageUpdate).not.toHaveBeenCalled(); }); it("retains rejected Git canary findings in the terminal result", async () => { const fact = { check: "core/doctor/config-readable", code: "doctor-failed", message: "The configured state directory is not readable.", affectedKey: "stateDir", }; mocks.validateCanary.mockResolvedValue({ status: "error", reason: "doctor-failed", phase: "doctor", durationMs: 1, logTail: [fact.message], steps: [ { name: "candidate doctor", command: "openclaw doctor", cwd: "/candidate", durationMs: 1, exitCode: 1, stderrTail: fact.message, failureFacts: [fact], }, ], }); const repair = await import("./update-command-repair.js"); vi.spyOn(repair, "runUpdateCommandRepair").mockResolvedValue({ status: "unavailable", attempts: [], finalValidation: { ok: false, score: 0, summary: fact.message }, }); mocks.runGitUpdate.mockImplementation( async (params: Parameters[0]) => { if (!params.validateCandidate) { throw new Error("Expected the Git candidate validation callback"); } await params.validateCandidate("/candidate"); return { ...successfulUpdate, mode: "git" }; }, ); const execution = await executeMutableUpdate(executionParams("git")); expect(execution?.result).toMatchObject({ status: "error", reason: "doctor-failed", steps: [{ failureFacts: [fact] }], }); expect(mocks.serviceStopped).toBe(false); }); });