import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { Command } from "commander"; import { assert, describe, expect, it, vi } from "vitest"; import { withTriageTerminal } from "../../commands/triage.test-support.js"; import * as tempRoot from "../../infra/tmp-openclaw-dir.js"; import * as packageMetadata from "../../infra/update-check-package-target.js"; import * as updateCheck from "../../infra/update-check.js"; import { createManagedHandoffLeaseStore } from "../../infra/update-managed-service-handoff-lease.js"; import { finishUpdateRun, getUpdateRun } from "../../infra/update-run-ledger.js"; import type { UpdateRecoveryFence } from "../../infra/update-run-recovery.js"; import { defaultRuntime } from "../../runtime.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 * as oneShotExit from "../one-shot-exit.js"; import { registerUpdateCli } from "../update-cli.js"; import * as shared from "./shared.js"; import * as execution from "./update-command-execution.js"; import * as executorOwner from "./update-command-executor.js"; import { installFreshUpdateFixture, targetMetadata } from "./update-command-fresh.test-support.js"; import * as initialization from "./update-command-initialization.js"; import * as packageUpdate from "./update-command-package.js"; import * as commandRun from "./update-command-run.js"; import * as servicePlan from "./update-command-service-plan.js"; import { deferUpdateCommandTerminalResult, publishUpdateCommandTerminalResult, resolveSettledUpdateCommandResult, } from "./update-command-terminal.js"; import * as commandTriage from "./update-command-triage.js"; import { updateCommand } from "./update-command.js"; const promptConfirm = vi.hoisted(() => vi.fn(async () => false)); vi.mock("@clack/prompts", async (original) => ({ ...(await original()), confirm: promptConfirm, })); const { fixture, dirs } = installFreshUpdateFixture(); const inheritedRunIds = [ undefined, "f9ccab65-df92-4ba2-84b9-d15c9c37c9a0", " 1c5a25f3-f46a-408b-a87f-a0f0d1f80ee7 ", " \t ", ] as const; function expectFreshStatePreserved() { expect(fs.existsSync(fixture.databasePath)).toBe(false); expect(packageUpdate.stagePackageInstallUpdate).not.toHaveBeenCalled(); expect(fs.readdirSync(fixture.root)).toEqual(["package.json"]); } function createSelectedTargetStateDatabase() { openOpenClawStateDatabase(); closeOpenClawStateDatabaseForTest(); const db = new DatabaseSync(fixture.databasePath); try { removePreparedWorkerOwnershipColumns(db); db.exec( "PRAGMA user_version=16; UPDATE schema_meta SET schema_version=16, app_version='2026.9.2'", ); } finally { db.close(); } } function writeStoredChannel(channel: "stable" | "beta") { const configPath = process.env.OPENCLAW_CONFIG_PATH!; fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify({ update: { channel } })); return configPath; } describe("update command admission with fresh state", () => { it("requires fresh downgrade confirmation without creating a run or exiting before release", async () => { await expect( updateCommand({ tag: "2026.9.2", json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "skipped", reason: "downgrade-confirmation-required" }), ); expect(createManagedHandoffLeaseStore().read(fixture.root).kind).toBe("absent"); expectFreshStatePreserved(); }); it("cancels a fresh downgrade without a ledger and exits only after release", async () => { promptConfirm.mockResolvedValue(false); vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined); const exitAfterOutput = oneShotExit.exitCliAfterOutput; const leases: string[] = []; vi.spyOn(oneShotExit, "exitCliAfterOutput").mockImplementation((...args) => { leases.push(createManagedHandoffLeaseStore().read(fixture.root).kind); return exitAfterOutput(...args); }); await withTriageTerminal(true, async () => { await expect(updateCommand({ tag: "2026.9.2", restart: false })).rejects.toMatchObject({ code: 0, }); }); expect(leases).toEqual(["absent"]); expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); expectFreshStatePreserved(); }); it("reports an invalid fresh dev target as a settled admission refusal", async () => { const config = process.env.OPENCLAW_CONFIG_PATH!; fs.mkdirSync(path.dirname(config), { recursive: true }); fs.writeFileSync(config, JSON.stringify({ update: { channel: "dev" } })); vi.spyOn(commandRun, "readDevUpdateTarget").mockImplementation(() => { throw new Error("fixture invalid dev target"); }); await expect( updateCommand({ tag: "2026.9.2", yes: true, json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "invalid-dev-target" }), ); expectFreshStatePreserved(); }); it.each([ { source: "metadata", cleanup: "healthy" }, { source: "metadata", cleanup: "release" }, { source: "channel", cleanup: "healthy" }, { source: "channel", cleanup: "close" }, { source: "channel", cleanup: "release" }, { source: "channel", cleanup: "coordinator" }, { source: "channel", cleanup: "close-and-coordinator" }, ])("publishes one settled fresh refusal ($source / $cleanup)", async ({ source, cleanup }) => { vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const denyRelease = () => { const db = new DatabaseSync( path.join(tempRoot.resolvePreferredOpenClawTmpDir(), "managed-update-handoffs.sqlite"), ); try { db.exec( "CREATE TRIGGER deny_refusal_release BEFORE DELETE ON managed_update_handoffs BEGIN SELECT RAISE(FAIL, 'fixture refusal release denied'); END", ); } finally { db.close(); } }; const observations: { closed: boolean; lease: string }[] = []; const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockImplementation(async () => { if (cleanup === "release") { denyRelease(); } if (cleanup.includes("close")) { throw new Error("fixture refused-stage cleanup failed"); } }), }; const legacyRelease = vi.fn(() => { throw new Error("fixture legacy coordinator release failed"); }); if (cleanup.includes("coordinator")) { vi.spyOn(initialization, "acquireLegacyUpdateInitializationFence").mockReturnValue({ path: path.join(fixture.root, "fixture-coordinator"), closed: false, release: legacyRelease, }); } vi.mocked(defaultRuntime.writeJson).mockImplementation(() => { observations.push({ closed: staged.close.mock.calls.length === 1, lease: createManagedHandoffLeaseStore().read(fixture.root).kind, }); }); if (source === "metadata") { vi.mocked(packageMetadata.fetchNpmPackageTargetStatus).mockImplementationOnce(async () => { if (cleanup === "release") { denyRelease(); } return { target: "2026.9.2", version: null, nodeEngine: null, error: "fixture registry unavailable", }; }); } else { writeStoredChannel("stable"); vi.mocked(packageUpdate.stagePackageInstallUpdate).mockImplementationOnce(async () => { writeStoredChannel("beta"); return staged; }); } const failure = await updateCommand({ yes: true, json: true, restart: false }).then( () => undefined, (error: unknown) => error, ); const reason = cleanup === "healthy" ? source === "metadata" ? "target-metadata-preflight" : "update-channel-changed" : "update-admission-cleanup-failed"; expect(observations).toEqual([ { closed: source === "channel", lease: cleanup === "release" ? "current" : "absent" }, ]); expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason }), ); expect(failure).toMatchObject({ code: 1 }); if (cleanup !== "healthy") { expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ recovery: { serviceRestartSafe: false, reason: "runtime-verification-failed" }, }), ); } expect(fs.existsSync(fixture.databasePath)).toBe(false); expect(staged.run).not.toHaveBeenCalled(); expect(legacyRelease).toHaveBeenCalledTimes(cleanup.includes("coordinator") ? 1 : 0); }); it("closes the stage when successful initialization is followed by legacy release failure", async () => { vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(staged); vi.spyOn(initialization, "initializeUpdateStateFromTarget").mockImplementation(async () => { createSelectedTargetStateDatabase(); }); const releaseError = new Error("fixture successful initialization release failed"); const release = vi.fn(() => { throw releaseError; }); vi.spyOn(initialization, "acquireLegacyUpdateInitializationFence").mockReturnValue({ path: path.join(fixture.root, "fixture-coordinator"), closed: false, release, }); await expect(updateCommand({ yes: true, json: true, restart: false })).rejects.toBe( releaseError, ); expect(staged.close).toHaveBeenCalledOnce(); expect(release).toHaveBeenCalledOnce(); expect(staged.run).not.toHaveBeenCalled(); expect(createManagedHandoffLeaseStore().read(fixture.root).kind).toBe("absent"); expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); }); it("settles fresh staging and executor before reporting changed admission selectors", async () => { vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(staged); vi.spyOn(initialization, "initializeUpdateStateFromTarget").mockImplementation(async () => { createSelectedTargetStateDatabase(); vi.stubEnv("OPENCLAW_STATE_DIR", path.join(path.dirname(fixture.root), "changed-profile")); }); const observations: { boundary: string; closed: boolean; lease: string }[] = []; const observe = (boundary: string) => { observations.push({ boundary, closed: staged.close.mock.calls.length === 1, lease: createManagedHandoffLeaseStore().read(fixture.root).kind, }); }; vi.mocked(defaultRuntime.writeJson).mockImplementation(() => observe("report")); const exitAfterOutput = oneShotExit.exitCliAfterOutput; vi.spyOn(oneShotExit, "exitCliAfterOutput").mockImplementation((...args) => { observe("exit"); return exitAfterOutput(...args); }); await expect( updateCommand({ tag: "2026.9.2", yes: true, json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect(observations).toEqual([ { boundary: "report", closed: true, lease: "absent" }, { boundary: "exit", closed: true, lease: "absent" }, ]); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "managed-service-preflight" }), ); expect(staged.run).not.toHaveBeenCalled(); expect(staged.close).toHaveBeenCalledOnce(); }); it.each([ ...inheritedRunIds.map((inheritedRunId) => ({ fault: "healthy", inheritedRunId })), { fault: "release-failure", inheritedRunId: undefined }, { fault: "stage-cleanup-failure", inheritedRunId: undefined }, ])( "settles fresh initialization before terminal publication ($fault, inherited run: $inheritedRunId)", async ({ fault, inheritedRunId }) => { vi.stubEnv("OPENCLAW_UPDATE_RUN_ID", inheritedRunId); let executorRunId: string | undefined; const withExecutor = executorOwner.withUpdateCommandExecutor; vi.spyOn(executorOwner, "withUpdateCommandExecutor").mockImplementation( (runId, operation) => { executorRunId = runId; return withExecutor(runId, operation); }, ); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); vi.spyOn(defaultRuntime, "exit").mockImplementation((code) => { throw new Error(`fixture CLI exit ${code}`); }); let admittedRun: shared.UpdateCommandOptions["run"]; let triagePrepared = false; const prepareTriage = commandTriage.prepareUpdateCommandFailureTriage; vi.spyOn(commandTriage, "prepareUpdateCommandFailureTriage").mockImplementation( async (...args) => { const handler = await prepareTriage(...args); triagePrepared = true; return handler; }, ); let leaseAtPublication: string | undefined; vi.mocked(defaultRuntime.writeJson).mockImplementation(() => { leaseAtPublication = createManagedHandoffLeaseStore().read(fixture.root).kind; }); let outputAtCleanup = -1; let historyAtCleanup: string | undefined; const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockImplementation(async () => { outputAtCleanup = vi.mocked(defaultRuntime.writeJson).mock.calls.length; historyAtCleanup = admittedRun && getUpdateRun(admittedRun.runId, { env: admittedRun.env })?.status; if (fault === "stage-cleanup-failure") { throw new Error("fixture stage cleanup failed"); } if (fault === "release-failure") { const filename = path.join( tempRoot.resolvePreferredOpenClawTmpDir(), "managed-update-handoffs.sqlite", ); const db = new DatabaseSync(filename); try { db.exec( "CREATE TRIGGER deny_terminal_release BEFORE DELETE ON managed_update_handoffs BEGIN SELECT RAISE(FAIL, 'fixture final lease delete denied'); END", ); } finally { db.close(); } } }), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(staged); vi.spyOn(initialization, "initializeUpdateStateFromTarget").mockImplementation(async () => { expect(fs.existsSync(fixture.databasePath)).toBe(false); createSelectedTargetStateDatabase(); }); vi.spyOn(execution, "executeMutableUpdate").mockImplementation(async (params) => { // The installation work is complete; keep its real terminal publisher, // ledger, executor, and outer staged-package cleanup to prove ordering. admittedRun = params.opts.run; expect(triagePrepared).toBe(true); const result = { status: "ok" as const, mode: "npm" as const, root: fixture.root, steps: [], durationMs: 1, }; const publish = async (failure?: unknown) => { const settled = await resolveSettledUpdateCommandResult( { opts: params.opts, root: fixture.root }, result, failure, ); return publishUpdateCommandTerminalResult({ opts: params.opts }, settled.result, { rolledBack: false, }); }; if (!deferUpdateCommandTerminalResult(params.opts.run, publish)) { await publish(); } return null; }); const outcome = await updateCommand({ tag: "2026.9.2", yes: true, json: true, restart: false, }).then( () => undefined, (error: unknown) => error, ); expect(initialization.initializeUpdateStateFromTarget).toHaveBeenCalledOnce(); assert(admittedRun); expect(admittedRun.runId).toBe(executorRunId); expect(admittedRun.runId.trim()).not.toBe(""); expect(admittedRun.runId).toBe(inheritedRunId?.trim() || executorRunId); expect(outputAtCleanup).toBe(0); expect(historyAtCleanup).toBe("running"); expect(staged.close).toHaveBeenCalledOnce(); expect(leaseAtPublication).toBe(fault === "release-failure" ? "current" : "absent"); expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: fault === "healthy" ? "ok" : "error" }), ); assert(admittedRun); expect(getUpdateRun(admittedRun.runId, { env: admittedRun.env })?.status).toBe( fault === "healthy" ? "succeeded" : "failed", ); expect(outcome === undefined).toBe(fault === "healthy"); }, ); it("registered update CLI reports the installed version for fresh saved-dev previews", async () => { fs.mkdirSync(path.dirname(process.env.OPENCLAW_CONFIG_PATH!), { recursive: true }); fs.writeFileSync( process.env.OPENCLAW_CONFIG_PATH!, JSON.stringify({ update: { channel: "dev" } }), ); vi.stubEnv("OPENCLAW_GIT_DIR", path.join(fixture.root, "missing-checkout")); const program = new Command(); registerUpdateCli(program); await program.parseAsync(["update", "--dry-run", "--json", "--no-restart"], { from: "user" }); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ currentVersion: "2026.9.3", targetVersion: null, targetVersionReason: expect.stringContaining("Git"), switchToGit: true, }), ); expect(vi.mocked(defaultRuntime.writeJson).mock.calls[0]?.[0]).toHaveProperty("run", undefined); expectFreshStatePreserved(); }); it.each(inheritedRunIds)( "previews an older stable without runtime state (inherited run: %s)", async (inheritedRunId) => { vi.stubEnv("OPENCLAW_UPDATE_RUN_ID", inheritedRunId); await updateCommand({ tag: "2026.9.2", dryRun: true, json: true, restart: false }); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ dryRun: true, currentVersion: "2026.9.3", targetVersion: "2026.9.2", downgradeRisk: true, }), ); expectFreshStatePreserved(); }, ); it.each([{ channel: "stable" }, { tag: "latest" }])( "refuses unresolved registry metadata for %j before creating runtime state", async (target) => { vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); vi.mocked(updateCheck.resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: null, }); await expect( updateCommand({ ...target, yes: true, json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "target-metadata-preflight" }), ); expectFreshStatePreserved(); }, ); it.each(inheritedRunIds)( "reports exact package metadata failure without runtime state (inherited run: %s)", async (inheritedRunId) => { vi.stubEnv("OPENCLAW_UPDATE_RUN_ID", inheritedRunId); vi.mocked(packageMetadata.fetchNpmPackageTargetStatus).mockResolvedValue({ target: "2026.9.2", version: null, nodeEngine: null, error: "registry unavailable", }); await expect( updateCommand({ tag: "2026.9.2", yes: true, json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "target-metadata-preflight" }), ); expect(defaultRuntime.error).toHaveBeenCalledWith( expect.stringContaining("registry unavailable"), ); expectFreshStatePreserved(); }, ); it("selects the fresh managed profile's stored channel instead of the shell profile's channel", async () => { const shellConfigPath = process.env.OPENCLAW_CONFIG_PATH!; fs.mkdirSync(path.dirname(shellConfigPath), { recursive: true }); fs.writeFileSync(shellConfigPath, JSON.stringify({ update: { channel: "stable" } })); const serviceStateDir = dirs.make("openclaw-update-managed-profile-"); const serviceConfigPath = path.join(serviceStateDir, "openclaw.json"); fs.writeFileSync(serviceConfigPath, JSON.stringify({ update: { channel: "beta" } })); const serviceEnv = { ...process.env, OPENCLAW_STATE_DIR: serviceStateDir, OPENCLAW_CONFIG_PATH: serviceConfigPath, }; vi.spyOn(commandRun, "resolveUpdateCommandAdmissionEnv").mockResolvedValue(serviceEnv); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: false, error: "fixture-stop", }); vi.mocked(updateCheck.resolveNpmChannelTag).mockResolvedValue({ tag: "beta", version: "2026.9.2", }); await expect(updateCommand({ yes: true, json: true, restart: false })).rejects.toMatchObject({ code: 1, }); expect( vi.mocked(updateCheck.resolveNpmChannelTag).mock.calls.map(([params]) => params.channel), ).toEqual(["beta"]); expect(fs.existsSync(resolveOpenClawStateSqlitePath(serviceEnv))).toBe(false); expect(process.env.OPENCLAW_CONFIG_PATH).toBe(shellConfigPath); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "node-runtime-preflight" }), ); expectFreshStatePreserved(); }); it.each([ { channel: undefined, reason: "update-channel-changed" }, { channel: "stable" as const, reason: "node-runtime-preflight" }, ])( "fences a changed stored channel after target lookup with explicit channel=$channel", async ({ channel, reason }) => { const configPath = writeStoredChannel("stable"); vi.mocked(packageMetadata.fetchNpmPackageTargetStatus).mockImplementationOnce(async () => { writeStoredChannel("beta"); return targetMetadata; }); const runtime = vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: false, error: "fixture-stop", }); await expect( updateCommand({ channel, yes: true, json: true, restart: false }), ).rejects.toMatchObject({ code: 1 }); expect( vi.mocked(updateCheck.resolveNpmChannelTag).mock.calls.map(([params]) => params.channel), ).toEqual(["stable"]); expect(runtime).toHaveBeenCalledTimes(channel ? 1 : 0); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason }), ); expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual({ update: { channel: "beta" }, }); expectFreshStatePreserved(); }, ); it("refuses a stored-channel change during staging before target Doctor or activation", async () => { const configPath = writeStoredChannel("stable"); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockImplementationOnce(async () => { writeStoredChannel("beta"); return staged; }); const doctor = vi .spyOn(packageUpdate, "runPackageUpdateDoctor") .mockRejectedValue(new Error("Unexpected target Doctor")); await expect(updateCommand({ yes: true, json: true, restart: false })).rejects.toMatchObject({ code: 1, }); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "error", reason: "update-channel-changed" }), ); expect(doctor).not.toHaveBeenCalled(); expect(staged.run).not.toHaveBeenCalled(); expect(staged.close).toHaveBeenCalledOnce(); expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual({ update: { channel: "beta" }, }); expect(fs.existsSync(fixture.databasePath)).toBe(false); }); it("accepts target Doctor config changes that preserve the selected stored channel", async () => { const configPath = writeStoredChannel("stable"); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const staged = { root: fixture.root, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(staged); const migrated = { update: { channel: "stable" }, gateway: { mode: "local" }, meta: { lastTouchedVersion: "2026.9.2" }, }; const afterDoctor = new Error("Fixture stopped after target Doctor revalidation"); vi.spyOn(initialization, "initializeUpdateStateFromTarget").mockImplementation( async (params) => { await params.checkSchemas(); fs.writeFileSync(configPath, JSON.stringify(migrated)); await params.checkSchemas(); throw afterDoctor; }, ); await expect(updateCommand({ yes: true, json: true, restart: false })).rejects.toBe( afterDoctor, ); expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual(migrated); expect(staged.close).toHaveBeenCalledOnce(); expect(staged.run).not.toHaveBeenCalled(); expect(fs.existsSync(fixture.databasePath)).toBe(false); }); it("keeps fresh staging releasable for a supervised handoff before package activation", async () => { let fence: UpdateRecoveryFence | undefined; const withExecutor = executorOwner.withUpdateCommandExecutor; vi.spyOn(executorOwner, "withUpdateCommandExecutor").mockImplementation((runId, operation) => withExecutor(runId, async (executor) => { const enter = executor.enter.bind(executor); vi.spyOn(executor, "enter").mockImplementation(async (...args) => { fence = await enter(...args); return fence; }); return await operation(executor); }), ); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const staged = { root: fixture.root, run: vi.fn().mockRejectedValue(new Error("Unexpected package activation")), close: vi.fn().mockResolvedValue(undefined), }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(staged); const handoffStop = new Error("Fixture stopped after successful preflight handoff release"); vi.spyOn(initialization, "initializeUpdateStateFromTarget").mockImplementation(async () => { assert(fence); executorOwner.releaseUpdateCommandPreflightForHandoff(fence); throw handoffStop; }); await expect( updateCommand({ tag: "2026.9.2", yes: true, json: true, restart: true }), ).rejects.toBe(handoffStop); expect(staged.close).toHaveBeenCalledOnce(); expect(staged.run).not.toHaveBeenCalled(); expect(fs.existsSync(fixture.databasePath)).toBe(false); expect(fs.readdirSync(fixture.root)).toEqual(["package.json"]); }); }); it("fresh local artifact reaches compatible target staging without creating parent state", async () => { const source = dirs.make("openclaw-synthetic-artifact-"); const packageDir = path.join(source, "package"); fs.mkdirSync(packageDir); fs.writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.2", engines: { node: ">=22" }, openclaw: { schemaVersions: { state: 16, agent: 19 } }, }), ); const artifact = path.join(source, "candidate.tgz"); execFileSync("tar", ["-czf", artifact, "-C", source, "package"]); vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); vi.mocked(packageUpdate.stagePackageInstallUpdate).mockRejectedValue( new Error("artifact-staged"), ); const outcome = await updateCommand({ tag: artifact, yes: true, json: true, restart: false, }).then( () => "completed", (error: unknown) => (error instanceof Error ? error.message : "unknown"), ); expect(fs.existsSync(fixture.databasePath)).toBe(false); expect(outcome).toBe("artifact-staged"); }); it.each([16, undefined] as const)( "inspects artifact schema %s before canonical initialization and history", async (schema) => { const candidate = dirs.make("openclaw-artifact-candidate-"); fs.writeFileSync( path.join(candidate, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.2", engines: { node: ">=22" }, ...(schema === undefined ? {} : { openclaw: { schemaVersions: { state: schema, agent: 19 } } }), }), ); vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); const staged = { root: candidate, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; let privateState: string | undefined; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockImplementation(async (params) => { privateState = params.installEnv?.OPENCLAW_STATE_DIR; expect(privateState).not.toBe(process.env.OPENCLAW_STATE_DIR); expect(params.installEnv?.HOME).toBe(process.env.HOME); expect(params.managedServiceEnv?.OPENCLAW_STATE_DIR).toBe(process.env.OPENCLAW_STATE_DIR); expect(fs.existsSync(fixture.databasePath)).toBe(false); return staged; }); const runtime = vi .spyOn(servicePlan, "resolvePackageRuntimePreflight") .mockResolvedValue({ ok: true, value: {} }); const doctor = vi .spyOn(initialization, "initializeUpdateStateFromTarget") .mockImplementation(async (params) => { expect(params.root).toBe(candidate); expect(params.env.OPENCLAW_STATE_DIR).toBe(process.env.OPENCLAW_STATE_DIR); expect(fs.existsSync(fixture.databasePath)).toBe(false); if (schema === 16) { createSelectedTargetStateDatabase(); } else { openOpenClawStateDatabase(); closeOpenClawStateDatabaseForTest(); } }); const admission = vi .spyOn(commandRun, "admitUpdateCommandRun") .mockRejectedValue(new Error("artifact-admitted")); const outcome = await updateCommand({ tag: "file:/fixture/candidate.tgz", yes: true, json: true, restart: false, }).then( () => "completed", (error: unknown) => (error instanceof Error ? error.message : "unknown"), ); expect(staged.close).toHaveBeenCalledOnce(); expect(privateState).toBeDefined(); expect(fs.existsSync(path.dirname(privateState!))).toBe(false); if (schema === undefined) { expect(doctor).not.toHaveBeenCalled(); expect(admission).not.toHaveBeenCalled(); expect(fs.existsSync(fixture.databasePath)).toBe(false); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ reason: "target-metadata-preflight" }), ); } else { expect(outcome).toBe("artifact-admitted"); expect(doctor).toHaveBeenCalledOnce(); expect(runtime).toHaveBeenCalledWith( expect.objectContaining({ target: { version: "2026.9.2", nodeEngine: ">=22" } }), ); expect(admission).toHaveBeenCalledOnce(); const db = new DatabaseSync(fixture.databasePath, { readOnly: true }); try { expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: schema }); } finally { db.close(); } } }, ); it.each(["node", "concurrent-state"] as const)( "refuses artifact %s incompatibility before canonical Doctor or history", async (fault) => { const candidate = dirs.make("openclaw-artifact-refusal-"); fs.writeFileSync( path.join(candidate, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.2", engines: { node: ">=22" }, openclaw: { schemaVersions: { state: 16, agent: 19 } }, }), ); vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); let previous: Buffer | undefined; const stage = { root: candidate, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockImplementation(async () => { if (fault === "concurrent-state") { openOpenClawStateDatabase(); closeOpenClawStateDatabaseForTest(); previous = fs.readFileSync(fixture.databasePath); } return stage; }); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: false, error: "selected artifact requires a newer Node", }); const doctor = vi.spyOn(initialization, "initializeUpdateStateFromTarget"); const admission = vi.spyOn(commandRun, "admitUpdateCommandRun"); await updateCommand({ tag: "file:/fixture/candidate.tgz", yes: true, json: true, restart: false, }).catch(() => undefined); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ reason: fault === "node" ? "node-runtime-preflight" : "database-schema-preflight", }), ); expect(doctor).not.toHaveBeenCalled(); expect(admission).not.toHaveBeenCalled(); expect(stage.run).not.toHaveBeenCalled(); expect(stage.close).toHaveBeenCalledOnce(); if (previous) { expect(fs.readFileSync(fixture.databasePath)).toEqual(previous); } else { expect(fs.existsSync(fixture.databasePath)).toBe(false); } }, ); it("requires confirmation for an inspected older artifact without a TTY", async () => { const candidate = dirs.make("artifact-downgrade-"); fs.writeFileSync( path.join(candidate, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.2", openclaw: { schemaVersions: { state: 16, agent: 19 } }, }), ); vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); const stage = { root: candidate, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(stage); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const doctor = vi .spyOn(initialization, "initializeUpdateStateFromTarget") .mockRejectedValue(new Error("confirmation was bypassed")); const admission = vi.spyOn(commandRun, "admitUpdateCommandRun"); await updateCommand({ tag: "file:/fixture/older.tgz", json: true, restart: false }).catch( () => undefined, ); expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ status: "skipped", reason: "downgrade-confirmation-required" }), ); expect(doctor).not.toHaveBeenCalled(); expect(admission).not.toHaveBeenCalled(); expect(stage.close).toHaveBeenCalledOnce(); expect(fs.existsSync(fixture.databasePath)).toBe(false); }); it.each([17, 18])( "admits compatible parent history before artifact schema %s migration", async (schema) => { const candidate = dirs.make("artifact-forward-"); fs.writeFileSync( path.join(candidate, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.4", engines: { node: ">=22" }, openclaw: { schemaVersions: { state: schema, agent: 19 } }, }), ); vi.mocked(shared.resolveTargetVersion).mockResolvedValue(null); const stage = { root: candidate, run: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; vi.mocked(packageUpdate.stagePackageInstallUpdate).mockResolvedValue(stage); vi.spyOn(servicePlan, "resolvePackageRuntimePreflight").mockResolvedValue({ ok: true, value: {}, }); const doctor = vi .spyOn(initialization, "initializeUpdateStateFromTarget") .mockImplementation(async () => { openOpenClawStateDatabase(); closeOpenClawStateDatabaseForTest(); const db = new DatabaseSync(fixture.databasePath); try { db.exec(`PRAGMA user_version=${schema}; UPDATE schema_meta SET schema_version=${schema}`); } finally { db.close(); } }); const execute = vi .spyOn(execution, "executeMutableUpdate") .mockImplementation(async (params) => { const run = params.opts.run; assert(run); const db = new DatabaseSync(fixture.databasePath, { readOnly: true }); try { expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 17 }); } finally { db.close(); } expect(getUpdateRun(run.runId, { env: run.env })?.status).toBe("running"); finishUpdateRun( run.runId, { status: "skipped", reason: "fixture-before-forward-migration" }, { env: run.env }, ); return null; }); const outcome = await updateCommand({ tag: "file:/fixture/forward.tgz", yes: true, json: true, restart: false, }).then( () => "admitted", () => "rejected", ); expect(outcome).toBe("admitted"); expect(doctor.mock.calls.length).toBe(0); expect(execute).toHaveBeenCalledOnce(); expect(stage.close).toHaveBeenCalledOnce(); }, );