| |
| 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<unknown>; |
| }) => { |
| expect(validateCandidate).toBeTypeOf("function"); |
| await validateCandidate?.("/candidate"); |
| accepted(); |
| return successfulUpdate; |
| }; |
| mocks.runPackageUpdate.mockImplementation(runStagedUpdate); |
| mocks.runGitUpdate.mockImplementation(runStagedUpdate); |
| const onStepComplete = vi.fn<NonNullable<UpdateStepProgress["onStepComplete"]>>(); |
|
|
| 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<unknown>; |
| }) => { |
| 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<typeof import("./update-command-plugin-preflight.js")>( |
| "./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<typeof import("./update-command-git.js").updateGitInstall>[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<typeof import("./update-command-git.js").updateGitInstall>[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); |
| }); |
| }); |
|
|