import { EventEmitter } from "node:events";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
import { writePackageDistInventory } from "../infra/package-dist-inventory.js";
import { isBetaTag } from "../infra/update-channels.js";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { withEnvAsync } from "../test-utils/env.js";
import { VERSION } from "../version.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
import { isOwningNpmCommand } from "./update-cli.test-helpers.js";

const confirm = vi.fn();
const select = vi.fn();
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
const isCancel = (value: unknown) => value === "cancel";

const readPackageName = vi.fn();
const readPackageVersion = vi.fn();
const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const serviceStop = vi.fn();
const serviceRestart = vi.fn();
const prepareRestartScript = vi.fn();
const runRestartScript = vi.fn();
const mockedRunDaemonInstall = vi.fn();
const serviceReadCommand = vi.fn();
const serviceReadRuntime = vi.fn();
const mockGetSelfAndAncestorPidsSync = vi.fn(() => new Set<number>([process.pid]));
const inspectPortUsage = vi.fn();
const classifyPortListener = vi.fn();
const formatPortDiagnostics = vi.fn();
const probeGateway = vi.fn();
const pathExists = vi.fn();
const syncPluginsForUpdateChannel = vi.fn();
const updateNpmInstalledPlugins = vi.fn();
const loadInstalledPluginIndexInstallRecords = vi.fn(
  async (params: { config?: OpenClawConfig } = {}) => params.config?.plugins?.installs ?? {},
);
const checkShellCompletionStatus = vi.fn();
const ensureCompletionCacheExists = vi.fn();
const installCompletion = vi.fn();
const legacyConfigRepairMocks = vi.hoisted(() => ({
  repairLegacyConfigForUpdateChannel: vi.fn(),
}));
const launchdUpdateCleanupMocks = vi.hoisted(() => ({
  disableCurrentOpenClawUpdateLaunchdJob: vi.fn(async () => false),
}));
const nodeVersionSatisfiesEngine = vi.fn();
const execFile = vi.fn((...args: unknown[]) => {
  const callback = args.at(-1);
  if (typeof callback === "function") {
    callback(null, new Date(Date.now() - 1000).toString(), "");
  }
  return new EventEmitter();
});
const spawn = vi.fn();
const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture();

vi.mock("@clack/prompts", () => ({
  confirm,
  select,
  isCancel,
  spinner,
}));

// Mock the update-runner module
vi.mock("../infra/update-runner.js", () => ({
  runGatewayUpdate: vi.fn(),
}));

vi.mock("../infra/openclaw-root.js", () => ({
  resolveOpenClawPackageRoot: vi.fn(),
  resolveOpenClawPackageRootSync: vi.fn(() => process.cwd()),
}));

vi.mock("../config/config.js", () => ({
  assertConfigWriteAllowedInCurrentMode: () => {
    if (process.env.OPENCLAW_NIX_MODE === "1") {
      throw new Error(
        [
          "Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.",
          "Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.",
          "Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
          "OpenClaw Nix overview: https://docs.openclaw.ai/install/nix",
        ].join("\n"),
      );
    }
  },
  ConfigMutationConflictError: class ConfigMutationConflictError extends Error {
    readonly currentHash: string | null;

    constructor(message: string, params: { currentHash: string | null }) {
      super(message);
      this.name = "ConfigMutationConflictError";
      this.currentHash = params.currentHash;
    }
  },
  parseConfigJson5: (raw: string) => {
    try {
      return { ok: true, parsed: JSON.parse(raw) };
    } catch (err) {
      return { ok: false, error: String(err) };
    }
  },
  readConfigFileSnapshot: vi.fn(),
  readSourceConfigBestEffort: vi.fn(),
  mutateConfigFileWithRetry: vi.fn(),
  replaceConfigFile: vi.fn(),
  resolveGatewayPort: vi.fn(() => 18789),
}));

vi.mock("../infra/update-check.js", () => ({
  checkUpdateStatus: vi.fn(),
  compareSemverStrings: vi.fn((left: string | null, right: string | null) => {
    const parse = (value: string | null) => {
      if (!value) {
        return null;
      }
      const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
      if (!match) {
        return null;
      }
      return [
        Number.parseInt(match[1] ?? "0", 10),
        Number.parseInt(match[2] ?? "0", 10),
        Number.parseInt(match[3] ?? "0", 10),
      ] as const;
    };
    const a = parse(left);
    const b = parse(right);
    if (!a || !b) {
      return null;
    }
    for (let index = 0; index < a.length; index += 1) {
      const diff = a[index] - b[index];
      if (diff !== 0) {
        return diff;
      }
    }
    return 0;
  }),
  fetchNpmPackageTargetStatus: vi.fn(),
  fetchNpmTagVersion: vi.fn(),
  resolveNpmChannelTag: vi.fn(),
}));

vi.mock("../infra/runtime-guard.js", () => ({
  nodeVersionSatisfiesEngine,
  parseSemver: (version: string | null) => {
    if (!version) {
      return null;
    }
    const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
    if (!match) {
      return null;
    }
    return {
      major: Number.parseInt(match[1] ?? "0", 10),
      minor: Number.parseInt(match[2] ?? "0", 10),
      patch: Number.parseInt(match[3] ?? "0", 10),
    };
  },
}));

vi.mock("../infra/restart-stale-pids.js", () => ({
  getSelfAndAncestorPidsSync: () => mockGetSelfAndAncestorPidsSync(),
}));

vi.mock("../infra/update-managed-service-handoff-cleanup.js", () => ({
  cleanupStaleManagedServiceUpdateHandoffs: vi.fn(async () => 0),
}));

vi.mock("node:child_process", async () => {
  const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
  return {
    ...actual,
    execFile,
    spawn,
    spawnSync: vi.fn(() => ({
      pid: 0,
      output: [],
      stdout: "",
      stderr: "",
      status: 0,
      signal: null,
    })),
  };
});

vi.mock("../process/exec.js", () => ({
  runCommandWithTimeout: vi.fn(),
}));

vi.mock("../utils.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../utils.js")>();
  return {
    ...actual,
    displayString: (input: string) => input,
    isRecord: (value: unknown) =>
      typeof value === "object" && value !== null && !Array.isArray(value),
    pathExists: (...args: unknown[]) => pathExists(...args),
    resolveConfigDir: () => "/tmp/openclaw-config",
  };
});

vi.mock("../plugins/update.js", () => ({
  resolveTrustedSourceLinkedOfficialClawHubSpec: vi.fn(() => undefined),
  resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined),
  syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args),
  updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
}));

vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
  const actual =
    await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
  return {
    ...actual,
    loadInstalledPluginIndexInstallRecords,
    writePersistedInstalledPluginIndexInstallRecords: vi.fn(async () => undefined),
  };
});

vi.mock("./update-cli/post-core-plugin-convergence.js", () => ({
  convergenceWarningsToOutcomes: (convergence: {
    warnings: Array<{ pluginId?: string; message: string }>;
    errored: boolean;
  }) => ({
    warnings: convergence.warnings,
    outcomes: convergence.warnings
      .filter((warning): warning is { pluginId: string; message: string } =>
        Boolean(warning.pluginId),
      )
      .map((warning) => ({
        pluginId: warning.pluginId,
        status: "error",
        message: warning.message,
      })),
    errored: convergence.errored,
  }),
  runPostCorePluginConvergence: vi.fn(async (params: { baselineInstallRecords?: unknown }) => ({
    changes: [],
    warnings: [],
    errored: false,
    smokeFailures: [],
    installRecords: params.baselineInstallRecords ?? {},
  })),
}));

vi.mock("../daemon/service.js", () => ({
  readGatewayServiceState: async () => {
    const command = await serviceReadCommand();
    const env = {
      ...process.env,
      ...(command && typeof command === "object" && "environment" in command
        ? (command.environment as NodeJS.ProcessEnv | undefined)
        : undefined),
    };
    const [loaded, runtime] = await Promise.all([
      serviceLoaded({ env }).catch(() => false),
      serviceReadRuntime(env).catch(() => undefined),
    ]);
    return {
      installed: command !== null,
      loaded,
      running: runtime?.status === "running",
      env,
      command,
      runtime,
    };
  },
  resolveGatewayService: vi.fn(() => ({
    isLoaded: (...args: unknown[]) => serviceLoaded(...args),
    readCommand: (...args: unknown[]) => serviceReadCommand(...args),
    readRuntime: (...args: unknown[]) => serviceReadRuntime(...args),
    stop: (...args: unknown[]) => serviceStop(...args),
    restart: (...args: unknown[]) => serviceRestart(...args),
  })),
}));

vi.mock("../daemon/launchd.js", async (importOriginal) => ({
  ...(await importOriginal<typeof import("../daemon/launchd.js")>()),
  disableCurrentOpenClawUpdateLaunchdJob:
    launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob,
}));

vi.mock("../infra/ports.js", () => ({
  inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
  classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
  formatPortDiagnostics: (...args: unknown[]) => formatPortDiagnostics(...args),
}));

vi.mock("../gateway/probe.js", () => ({
  probeGateway: (...args: unknown[]) => probeGateway(...args),
}));

vi.mock("./update-cli/restart-helper.js", () => ({
  prepareRestartScript: (...args: unknown[]) => prepareRestartScript(...args),
  runRestartScript: (...args: unknown[]) => runRestartScript(...args),
}));

// Mock doctor (heavy module; should not run in unit tests)
vi.mock("../commands/doctor.js", () => ({
  doctorCommand: vi.fn(),
}));
vi.mock("../commands/doctor-completion.js", () => ({
  checkShellCompletionStatus: (...args: unknown[]) => checkShellCompletionStatus(...args),
  ensureCompletionCacheExists: (...args: unknown[]) => ensureCompletionCacheExists(...args),
}));
vi.mock("../commands/doctor/legacy-config-repair.js", () => ({
  repairLegacyConfigForUpdateChannel: legacyConfigRepairMocks.repairLegacyConfigForUpdateChannel,
}));
vi.mock("./completion-runtime.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("./completion-runtime.js")>();
  return {
    ...actual,
    installCompletion: (...args: unknown[]) => installCompletion(...args),
  };
});
// Mock the daemon-cli module
vi.mock("./daemon-cli.js", () => ({
  runDaemonInstall: mockedRunDaemonInstall,
  runDaemonRestart: vi.fn(),
}));

// Mock the runtime
vi.mock("../runtime.js", () => ({
  defaultRuntime: runtimeCapture,
}));

const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
const {
  mutateConfigFileWithRetry,
  readConfigFileSnapshot,
  readSourceConfigBestEffort,
  replaceConfigFile,
} = await import("../config/config.js");
const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
  await import("../infra/update-check.js");
const { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } =
  await import("../infra/update-control-plane-sentinel.js");
const { runCommandWithTimeout } = await import("../process/exec.js");
const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js");
const { doctorCommand } = await import("../commands/doctor.js");
const { defaultRuntime } = await import("../runtime.js");
const { updateCommand, updateFinalizeCommand, updateStatusCommand, updateWizardCommand } =
  await import("./update-cli.js");
const updateCliShared = await import("./update-cli/shared.js");
const { ensureGitCheckout, resolveGitInstallDir } = updateCliShared;
const { spawnSync } = await import("node:child_process");

function requireValue<T>(value: T | undefined, label: string): T {
  if (value === undefined) {
    throw new Error(`expected ${label}`);
  }
  return value;
}

type UpdateCliScenario = {
  name: string;
  run: () => Promise<void>;
  assert: () => void;
};

describe("update-cli", () => {
  const fixtureRoot = "/tmp/openclaw-update-tests";
  let fixtureCount = 0;
  const tempDirsToCleanup = new Set<string>();

  const createCaseDir = (prefix: string) => {
    const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
    // Tests only need a stable path; the directory does not have to exist because all I/O is mocked.
    return dir;
  };

  const createTrackedTempDir = async (prefix: string) => {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
    tempDirsToCleanup.add(dir);
    return dir;
  };

  const baseConfig = {} as OpenClawConfig;
  const baseSnapshot: ConfigFileSnapshot = {
    path: "/tmp/openclaw-config.json",
    exists: true,
    raw: "{}",
    parsed: {},
    resolved: baseConfig,
    sourceConfig: baseConfig,
    valid: true,
    config: baseConfig,
    runtimeConfig: baseConfig,
    issues: [],
    warnings: [],
    legacyIssues: [],
  };

  const setTty = (value: boolean | undefined) => {
    Object.defineProperty(process.stdin, "isTTY", {
      value,
      configurable: true,
    });
  };

  const setStdoutTty = (value: boolean | undefined) => {
    Object.defineProperty(process.stdout, "isTTY", {
      value,
      configurable: true,
    });
  };

  const mockPackageInstallStatus = (root: string) => {
    vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(root);
    vi.mocked(checkUpdateStatus).mockResolvedValue({
      root,
      installKind: "package",
      packageManager: "npm",
      deps: {
        manager: "npm",
        status: "ok",
        lockfilePath: null,
        markerPath: null,
      },
    });
  };

  const expectUpdateCallChannel = (channel: string) => {
    const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
    expect(call?.channel).toBe(channel);
    return call;
  };

  const commandCall = (index = 0) => {
    const calls = vi.mocked(runCommandWithTimeout).mock.calls as unknown as Array<
      [string[], Record<string, unknown>]
    >;
    return calls[index];
  };

  const commandCalls = () =>
    vi.mocked(runCommandWithTimeout).mock.calls as unknown as Array<
      [string[], Record<string, unknown>]
    >;

  const packageInstallCommandCall = () =>
    commandCalls().find(([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g");

  const packagePackCommandCall = () =>
    commandCalls().find(([argv]) => argv[0] === "npm" && argv[1] === "pack");

  const stripOpenClawPackageAlias = (spec: string) => {
    const trimmed = spec.trim();
    return trimmed.toLowerCase().startsWith("openclaw@")
      ? trimmed.slice("openclaw@".length)
      : trimmed;
  };

  const isNpmGitPackageSpec = (spec: string) => {
    const target = stripOpenClawPackageAlias(spec);
    const [repo] = target.split("#", 1);
    const isGitHubShorthand =
      !!repo &&
      !repo.startsWith(".") &&
      !repo.startsWith("/") &&
      !repo.startsWith("@") &&
      repo.split("/").length === 2 &&
      repo.split("/").every((part) => /^[^\s/:@]+$/u.test(part));
    let isHttpGitUrl = false;
    try {
      const url = new URL(target);
      const pathname = url.pathname.replace(/\/+$/u, "");
      const pathParts = pathname.split("/").filter(Boolean);
      isHttpGitUrl =
        (url.protocol === "https:" || url.protocol === "http:") &&
        (pathname.endsWith(".git") ||
          (url.hostname.toLowerCase() === "github.com" && pathParts.length === 2));
    } catch {
      isHttpGitUrl = false;
    }
    return (
      /^github:/i.test(target) ||
      /^git(?:\+|:)/i.test(target) ||
      /^ssh:\/\//i.test(target) ||
      /^[^@\s]+@[^:\s]+:[^#\s]+(?:#.*)?$/u.test(target) ||
      isHttpGitUrl ||
      isGitHubShorthand
    );
  };

  const doctorCommandCall = () =>
    commandCalls().find(
      ([argv]) => argv[2] === "doctor" && argv[3] === "--non-interactive" && argv[4] === "--fix",
    );

  const gatewayCommandCall = (entryPath: string, action: "install" | "restart") =>
    commandCalls().find(
      ([argv]) => argv[1] === entryPath && argv[2] === "gateway" && argv[3] === action,
    );

  const spawnCall = (index = 0) => {
    const calls = spawn.mock.calls as unknown as Array<
      [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
    >;
    return calls[index];
  };

  const spawnSyncCall = (index = 0) => {
    const calls = vi.mocked(spawnSync).mock.calls as unknown as Array<
      [string, string[], { env?: NodeJS.ProcessEnv; timeout?: number }]
    >;
    return calls[index];
  };

  const syncPluginCall = (index = 0) => {
    const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array<
      [{ channel?: string; config?: OpenClawConfig }]
    >;
    return calls[index]?.[0];
  };

  const npmPluginUpdateCall = (index = 0) => {
    const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array<
      [{ config?: OpenClawConfig; timeoutMs?: number }]
    >;
    return calls[index]?.[0];
  };
  const lastNpmPluginUpdateCall = () =>
    npmPluginUpdateCall(updateNpmInstalledPlugins.mock.calls.length - 1);

  const replaceConfigCall = (index = 0) => vi.mocked(replaceConfigFile).mock.calls[index]?.[0];
  const lastReplaceConfigCall = () =>
    replaceConfigCall(vi.mocked(replaceConfigFile).mock.calls.length - 1);
  const setupConfigMutationWithRetryMock = () => {
    vi.mocked(mutateConfigFileWithRetry).mockImplementation(async (params) => {
      const snapshot = await readConfigFileSnapshot();
      const nextConfig = structuredClone(snapshot.sourceConfig) as OpenClawConfig;
      await params.mutate(nextConfig, {
        snapshot,
        previousHash: snapshot.hash ?? null,
        attempt: 0,
      });
      await replaceConfigFile({
        nextConfig,
        ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
      });
      return {
        path: snapshot.path,
        previousHash: snapshot.hash ?? null,
        snapshot,
        nextConfig,
        persistedHash: snapshot.hash ?? null,
        result: undefined,
        attempts: 1,
        afterWrite: { mode: "none", reason: "test" },
        followUp: { mode: "none", reason: "test", requiresRestart: false },
      };
    });
  };

  const writeJsonCall = (index = 0) => vi.mocked(defaultRuntime.writeJson).mock.calls[index]?.[0];
  const lastWriteJsonCall = () =>
    writeJsonCall(vi.mocked(defaultRuntime.writeJson).mock.calls.length - 1);

  const probeGatewayCall = (index = 0) => probeGateway.mock.calls[index]?.[0];

  const pluginWarning = (result?: UpdateRunResult) => result?.postUpdate?.plugins?.warnings?.[0];
  const pluginOutcome = (result?: UpdateRunResult) => result?.postUpdate?.plugins?.npm.outcomes[0];

  const expectPackageInstallSpec = (spec: string) => {
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    let installSpec = spec;
    if (isNpmGitPackageSpec(spec)) {
      const packCall = packagePackCommandCall();
      expect(packCall?.[0]).toEqual([
        "npm",
        "pack",
        spec,
        "--pack-destination",
        expect.any(String),
        "--json",
        "--loglevel=error",
      ]);
      const packDir = packCall?.[0][4];
      if (!packDir) {
        throw new Error("Expected package pack directory");
      }
      installSpec = path.join(packDir, "openclaw-9999.0.0.tgz");
    } else {
      expect(packagePackCommandCall()).toBeUndefined();
    }
    const call = packageInstallCommandCall();
    expect(call?.[0]).toEqual([
      "npm",
      "i",
      "-g",
      installSpec,
      "--no-fund",
      "--no-audit",
      "--loglevel=error",
      "--min-release-age=0",
    ]);
    if (call?.[1] === undefined) {
      throw new Error("Expected package install command options");
    }
  };

  const statfsFixture = (params: {
    bavail: number;
    bsize?: number;
    blocks?: number;
  }): ReturnType<typeof fsSync.statfsSync> => ({
    type: 0,
    bsize: params.bsize ?? 1024,
    blocks: params.blocks ?? 2_000_000,
    bfree: params.bavail,
    bavail: params.bavail,
    files: 0,
    ffree: 0,
  });

  const makeOkUpdateResult = (overrides: Partial<UpdateRunResult> = {}): UpdateRunResult =>
    ({
      status: "ok",
      mode: "git",
      steps: [],
      durationMs: 100,
      ...overrides,
    }) as UpdateRunResult;

  const runUpdateCliScenario = async (testCase: UpdateCliScenario) => {
    vi.clearAllMocks();
    await testCase.run();
    testCase.assert();
  };

  const runRestartFallbackScenario = async (params: { daemonInstall: "ok" | "fail" }) => {
    vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
    if (params.daemonInstall === "fail") {
      vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed"));
    } else {
      vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
    }
    prepareRestartScript.mockResolvedValue(null);
    serviceLoaded.mockResolvedValue(true);
    vi.mocked(runDaemonRestart).mockResolvedValue(true);

    await updateCommand({});

    expect(runDaemonInstall).toHaveBeenCalledWith({
      force: true,
      json: undefined,
    });
    expect(runDaemonRestart).toHaveBeenCalledTimes(1);
  };

  const setupNonInteractiveDowngrade = async () => {
    const tempDir = createCaseDir("openclaw-update");
    setTty(false);
    readPackageVersion.mockResolvedValue("2.0.0");

    mockPackageInstallStatus(tempDir);
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "0.0.1",
    });
    vi.mocked(runGatewayUpdate).mockResolvedValue({
      status: "ok",
      mode: "npm",
      steps: [],
      durationMs: 100,
    });
    vi.mocked(defaultRuntime.error).mockClear();
    vi.mocked(defaultRuntime.exit).mockClear();

    return tempDir;
  };

  const setupUpdatedRootRefresh = (params?: {
    gatewayUpdateImpl?: (root: string) => Promise<UpdateRunResult>;
    entrypoints?: string[];
  }) => {
    const root = createCaseDir("openclaw-updated-root");
    const entrypoints = params?.entrypoints ?? [path.join(root, "dist", "entry.js")];
    pathExists.mockImplementation(async (candidate: string) => entrypoints.includes(candidate));
    if (params?.gatewayUpdateImpl) {
      vi.mocked(runGatewayUpdate).mockImplementation(() => params.gatewayUpdateImpl!(root));
    } else {
      vi.mocked(runGatewayUpdate).mockResolvedValue({
        status: "ok",
        mode: "npm",
        root,
        steps: [],
        durationMs: 100,
      });
    }
    serviceLoaded.mockResolvedValue(true);
    return { root, entrypoints };
  };

  beforeEach(() => {
    vi.clearAllMocks();
    resetRuntimeCapture();
    spawn.mockImplementation(() => {
      const child = new EventEmitter() as EventEmitter & {
        once: EventEmitter["once"];
      };
      queueMicrotask(() => {
        child.emit("exit", 0, null);
      });
      return child;
    });
    vi.mocked(defaultRuntime.exit).mockImplementation(() => {});
    vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd());
    vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
    vi.mocked(readSourceConfigBestEffort).mockResolvedValue(baseSnapshot.config);
    setupConfigMutationWithRetryMock();
    vi.mocked(fetchNpmTagVersion).mockResolvedValue({
      tag: "latest",
      version: "9999.0.0",
    });
    vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
      target: "latest",
      version: "9999.0.0",
      nodeEngine: ">=22.19.0",
    });
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "9999.0.0",
    });
    nodeVersionSatisfiesEngine.mockReturnValue(true);
    vi.mocked(checkUpdateStatus).mockResolvedValue({
      root: "/test/path",
      installKind: "git",
      packageManager: "pnpm",
      git: {
        root: "/test/path",
        sha: "abcdef1234567890",
        tag: "v1.2.3",
        branch: "main",
        upstream: "origin/main",
        dirty: false,
        ahead: 0,
        behind: 0,
        fetchOk: true,
      },
      deps: {
        manager: "pnpm",
        status: "ok",
        lockfilePath: "/test/path/pnpm-lock.yaml",
        markerPath: "/test/path/node_modules",
      },
      registry: {
        latestVersion: "1.2.3",
      },
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (argv[0] === "npm" && argv[1] === "pack") {
        const destination = argv[argv.indexOf("--pack-destination") + 1];
        if (destination) {
          await fs.writeFile(path.join(destination, "openclaw-9999.0.0.tgz"), "packed\n", "utf8");
        }
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });
    vi.spyOn(updateCliShared, "readPackageName").mockImplementation(readPackageName);
    vi.spyOn(updateCliShared, "readPackageVersion").mockImplementation(readPackageVersion);
    vi.spyOn(updateCliShared, "resolveGlobalManager").mockImplementation(resolveGlobalManager);
    readPackageName.mockResolvedValue("openclaw");
    readPackageVersion.mockResolvedValue("1.0.0");
    resolveGlobalManager.mockResolvedValue("npm");
    serviceStop.mockResolvedValue(undefined);
    serviceRestart.mockResolvedValue({ outcome: "completed" });
    serviceLoaded.mockResolvedValue(false);
    serviceReadCommand.mockImplementation(async () =>
      (await serviceLoaded()) ? { programArguments: ["openclaw", "gateway", "run"] } : null,
    );
    serviceReadRuntime.mockResolvedValue({
      status: "running",
      pid: 4242,
      state: "running",
    });
    mockGetSelfAndAncestorPidsSync.mockReturnValue(new Set<number>([process.pid]));
    prepareRestartScript.mockResolvedValue("/tmp/openclaw-restart-test.sh");
    runRestartScript.mockResolvedValue(undefined);
    inspectPortUsage.mockResolvedValue({
      port: 18789,
      status: "busy",
      listeners: [{ pid: 4242, command: "openclaw-gateway" }],
      hints: [],
    });
    classifyPortListener.mockReturnValue("gateway");
    formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "1.0.0",
        connId: "conn-test",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });
    pathExists.mockResolvedValue(false);
    syncPluginsForUpdateChannel.mockResolvedValue({
      changed: false,
      config: baseConfig,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    });
    updateNpmInstalledPlugins.mockResolvedValue({
      changed: false,
      config: baseConfig,
      outcomes: [],
    });
    checkShellCompletionStatus.mockResolvedValue({
      shell: "zsh",
      profileInstalled: false,
      cacheExists: false,
      cachePath: "/tmp/openclaw-completion.zsh",
      usesSlowPattern: false,
    });
    ensureCompletionCacheExists.mockResolvedValue(true);
    installCompletion.mockResolvedValue(undefined);
    vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
    vi.mocked(runDaemonRestart).mockResolvedValue(true);
    vi.mocked(doctorCommand).mockResolvedValue(undefined);
    legacyConfigRepairMocks.repairLegacyConfigForUpdateChannel.mockImplementation(
      async (params: { configSnapshot: ConfigFileSnapshot }) => ({
        snapshot: params.configSnapshot,
        repaired: false,
      }),
    );
    launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob.mockReset();
    launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob.mockResolvedValue(false);
    confirm.mockResolvedValue(false);
    select.mockResolvedValue("stable");
    vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
    setTty(false);
    setStdoutTty(false);
  });

  afterEach(async () => {
    if (tempDirsToCleanup.size === 0) {
      return;
    }
    await Promise.allSettled(
      [...tempDirsToCleanup].map(async (dir) => {
        await fs.rm(dir, { recursive: true, force: true });
      }),
    );
    tempDirsToCleanup.clear();
  });

  it("reads the initial update config without plugin schema validation", async () => {
    await updateCommand({ yes: true, restart: false });

    expect(vi.mocked(readConfigFileSnapshot).mock.calls[0]?.[0]).toEqual({
      skipPluginValidation: true,
    });
  });

  it("bounds completion cache refresh during update follow-up", async () => {
    const root = createCaseDir("openclaw-completion-timeout");
    pathExists.mockResolvedValue(true);

    await updateCliShared.tryWriteCompletionCache(root, false);

    const call = spawnSyncCall();
    expect(typeof call?.[0]).toBe("string");
    expect(call?.[1]).toEqual([path.join(root, "openclaw.mjs"), "completion", "--write-state"]);
    expect(call?.[2]?.env?.OPENCLAW_COMPLETION_SKIP_PLUGIN_COMMANDS).toBe("1");
    expect(call?.[2]?.timeout).toBe(30_000);
  });

  it("disarms legacy launchd updater jobs before refusing mutating updates in Nix mode", async () => {
    await withEnvAsync({ OPENCLAW_NIX_MODE: "1" }, async () => {
      await expect(updateCommand({ yes: true })).rejects.toThrow("OPENCLAW_NIX_MODE=1");
    });

    expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).toHaveBeenCalledOnce();
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(replaceConfigFile).not.toHaveBeenCalled();
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
  });

  it("logs friendly hint with manual refresh command when completion cache write times out", async () => {
    const root = createCaseDir("openclaw-completion-timeout-msg");
    pathExists.mockResolvedValue(true);
    const timeoutErr = Object.assign(new Error("spawnSync /usr/bin/node ETIMEDOUT"), {
      code: "ETIMEDOUT",
    });
    vi.mocked(spawnSync).mockReturnValueOnce({
      pid: 0,
      output: [],
      stdout: "",
      stderr: "",
      status: null,
      signal: null,
      error: timeoutErr,
    });
    vi.mocked(runtimeCapture.log).mockClear();

    await updateCliShared.tryWriteCompletionCache(root, false);

    const logOutput = vi
      .mocked(runtimeCapture.log)
      .mock.calls.map((call) => String(call[0]))
      .join("\n");
    expect(logOutput).toContain("timed out after 30s");
    expect(logOutput).toContain("openclaw completion --write-state");
    expect(logOutput).not.toContain("Error: spawnSync");
  });

  it("keeps update completion refresh best-effort when profile install fails", async () => {
    setTty(true);
    checkShellCompletionStatus.mockResolvedValue({
      shell: "zsh",
      profileInstalled: true,
      cacheExists: true,
      cachePath: "/tmp/openclaw-completion.zsh",
      usesSlowPattern: true,
    });
    installCompletion.mockRejectedValueOnce(new Error("EACCES: permission denied"));

    await updateCommand({ yes: true, restart: false });

    expect(installCompletion).toHaveBeenCalledWith("zsh", true, "openclaw");
    const logOutput = vi
      .mocked(runtimeCapture.log)
      .mock.calls.map((call) => String(call[0]))
      .join("\n");
    expect(logOutput).toContain("Shell completion refresh failed: EACCES: permission denied");
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("respawns into the updated package root before running post-update tasks", async () => {
    const { entrypoints } = setupUpdatedRootRefresh();

    await updateCommand({ yes: true, timeout: "1800" });

    const call = spawnCall();
    expect(call?.[0]).toMatch(/node/);
    expect(call?.[1]).toEqual([entrypoints[0], "update", "--yes", "--timeout", "1800"]);
    expect(call?.[2]?.stdio).toBe("inherit");
    expect(call?.[2]?.env?.NODE_DISABLE_COMPILE_CACHE).toBe("1");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE).toBe("1");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE_CHANNEL).toBe("dev");
    expect(call?.[2]?.env?.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBe("1.0.0");
    expect(vi.mocked(readConfigFileSnapshot).mock.calls[1]?.[0]).toEqual({
      skipPluginValidation: true,
      suppressFutureVersionWarning: true,
    });
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
    expect(runDaemonInstall).not.toHaveBeenCalled();
    expect(runDaemonRestart).not.toHaveBeenCalled();
  });

  it("finishes package updates when the post-core process writes a result but keeps handles open", async () => {
    setupUpdatedRootRefresh();
    const kill = vi.fn();
    spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => {
      const resultPath = (options as { env?: NodeJS.ProcessEnv }).env
        ?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
      if (!resultPath) {
        throw new Error("missing post-core result path");
      }
      queueMicrotask(() => {
        void fs.writeFile(resultPath, `${JSON.stringify({ status: "ok" })}\n`, "utf-8");
      });
      const child = new EventEmitter() as EventEmitter & {
        kill: typeof kill;
        once: EventEmitter["once"];
      };
      child.kill = kill;
      return child;
    });

    await updateCommand({ yes: true, restart: false });

    expect(kill).toHaveBeenCalledTimes(1);
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("does not restart a stopped managed gateway after post-core plugin errors", async () => {
    const root = createCaseDir("openclaw-update");
    const entryPath = path.join(root, "dist", "index.js");
    mockPackageInstallStatus(root);
    serviceLoaded.mockResolvedValue(true);
    pathExists.mockImplementation(async (candidate: string) => candidate === entryPath);
    spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => {
      const resultPath = (options as { env?: NodeJS.ProcessEnv }).env
        ?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
      if (!resultPath) {
        throw new Error("missing post-core result path");
      }
      queueMicrotask(() => {
        void fs.writeFile(
          resultPath,
          JSON.stringify({
            status: "error",
            changed: false,
            warnings: [
              {
                pluginId: "demo",
                reason: "missing-extension-entry: ./dist/index.js",
                message:
                  'Plugin "demo" failed post-core payload smoke check (missing-extension-entry): ./dist/index.js',
                guidance: ["Run openclaw doctor --fix to attempt automatic repair."],
              },
            ],
            sync: {
              changed: false,
              switchedToBundled: [],
              switchedToNpm: [],
              warnings: [],
              errors: [],
            },
            npm: {
              changed: false,
              outcomes: [
                {
                  pluginId: "demo",
                  status: "error",
                  message: "Plugin extension entry missing",
                },
              ],
            },
            integrityDrifts: [],
          }),
          "utf-8",
        );
      });
      const child = new EventEmitter() as EventEmitter & {
        kill: () => boolean;
        once: EventEmitter["once"];
      };
      child.kill = vi.fn(() => true);
      return child;
    });

    await updateCommand({ yes: true });

    expect(serviceStop).toHaveBeenCalled();
    expect(serviceRestart).not.toHaveBeenCalled();
    expect(runDaemonRestart).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
  });

  it("does not carry gateway service markers into the post-core update process", async () => {
    setupUpdatedRootRefresh();

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
        [GATEWAY_SERVICE_RUNTIME_PID_ENV]: "7777",
      },
      async () => {
        await updateCommand({ yes: true });
      },
    );

    const spawnEnv = spawnCall()?.[2]?.env;
    expect(spawnEnv?.OPENCLAW_SERVICE_MARKER).toBeUndefined();
    expect(spawnEnv?.OPENCLAW_SERVICE_KIND).toBeUndefined();
    expect(spawnEnv?.[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBeUndefined();
  });

  it("passes pre-update plugin install records into the post-core update process", async () => {
    setupUpdatedRootRefresh();
    const pluginInstallRecords = {
      demo: {
        source: "npm",
        spec: "@openclaw/demo@1.0.0",
        installPath: "/tmp/openclaw-demo-plugin",
      },
    } as const;
    const preUpdateConfig = {
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    let capturedRecords: unknown;
    let capturedSourceConfig: unknown;
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      parsed: preUpdateConfig,
      sourceConfig: preUpdateConfig,
      config: preUpdateConfig,
      runtimeConfig: preUpdateConfig,
    });
    loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords);
    spawn.mockImplementationOnce((_node, _argv, options) => {
      const env = (options as { env?: NodeJS.ProcessEnv }).env;
      const recordsPath = env?.OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH;
      const sourceConfigPath = env?.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH;
      if (!recordsPath) {
        throw new Error("missing post-core install records path");
      }
      if (!sourceConfigPath) {
        throw new Error("missing post-core source config path");
      }
      capturedRecords = JSON.parse(fsSync.readFileSync(recordsPath, "utf-8"));
      capturedSourceConfig = JSON.parse(fsSync.readFileSync(sourceConfigPath, "utf-8"));
      const child = new EventEmitter() as EventEmitter & {
        once: EventEmitter["once"];
      };
      queueMicrotask(() => {
        child.emit("exit", 0, null);
      });
      return child;
    });

    await updateCommand({ yes: true, restart: false });

    expect(capturedRecords).toEqual(pluginInstallRecords);
    expect(capturedSourceConfig).toEqual({
      sourceConfig: preUpdateConfig,
      authoredConfig: preUpdateConfig,
    });
    expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
  });

  it("respawns into the updated git root before requested channel persistence", async () => {
    const { entrypoints } = setupUpdatedRootRefresh({
      gatewayUpdateImpl: async (root) =>
        makeOkUpdateResult({
          mode: "git",
          root,
          before: { sha: "old-sha", version: "2026.4.26" },
          after: { sha: "new-sha", version: "2026.4.27" },
        }),
    });

    await updateCommand({ channel: "dev", yes: true, restart: false });

    const call = spawnCall();
    expect(call?.[0]).toMatch(/node/);
    expect(call?.[1]).toEqual([entrypoints[0], "update", "--no-restart", "--yes"]);
    expect(call?.[2]?.stdio).toBe("inherit");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE).toBe("1");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE_CHANNEL).toBe("dev");
    expect(call?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL).toBe("dev");
    expect(replaceConfigFile).not.toHaveBeenCalled();
    expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
  });

  it("keeps downgrade post-update work in the current process", async () => {
    const downgradedRoot = createCaseDir("openclaw-downgraded-root");
    setupUpdatedRootRefresh({
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: downgradedRoot,
          before: { version: "2026.4.14" },
          after: { version: "2026.4.10" },
        }),
    });
    readPackageVersion.mockResolvedValue("2026.4.14");
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "2026.4.10",
    });
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.10",
        connId: "downgraded-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await updateCommand({ yes: true, tag: "2026.4.10", restart: false });

    expect(spawn).not.toHaveBeenCalled();
    expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1);
    expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
    expect(runDaemonInstall).not.toHaveBeenCalled();
    expect(probeGateway).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("fails the update when the fresh process exits non-zero", async () => {
    setupUpdatedRootRefresh();
    spawn.mockImplementationOnce(() => {
      const child = new EventEmitter() as EventEmitter & {
        once: EventEmitter["once"];
      };
      queueMicrotask(() => {
        child.emit("exit", 2, null);
      });
      return child;
    });

    await expect(updateCommand({ yes: true })).rejects.toThrow(
      "post-update process exited with code 2",
    );

    expect(defaultRuntime.exit).toHaveBeenCalledWith(2);
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
  });

  it("post-core resume mode skips the core update and only runs post-update tasks", async () => {
    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ restart: false });
      },
    );

    expect(runGatewayUpdate).not.toHaveBeenCalled();
    const installCall = (
      vi.mocked(runCommandWithTimeout).mock.calls as unknown as Array<[string[], unknown]>
    ).find(([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g");
    expect(installCall).toBeUndefined();
    expect(
      vi
        .mocked(readConfigFileSnapshot)
        .mock.calls.some(
          ([options]) =>
            options?.skipPluginValidation === true && options.suppressFutureVersionWarning === true,
        ),
    ).toBe(true);
    expect(defaultRuntime.exit).toHaveBeenCalledWith(0);
    expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1);
    expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
    expect(spawn).not.toHaveBeenCalled();
  });

  it("post-core resume children exit after writing a plugin update result", async () => {
    const resultDir = createCaseDir("openclaw-post-core-result");
    const resultPath = path.join(resultDir, "plugins.json");
    await fs.mkdir(resultDir, { recursive: true });

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        OPENCLAW_UPDATE_POST_CORE_RESULT_PATH: resultPath,
      },
      async () => {
        await updateCommand({ restart: false });
      },
    );

    const result = JSON.parse(await fs.readFile(resultPath, "utf-8")) as {
      status?: string;
    };
    expect(result.status).toBe("ok");
    expect(defaultRuntime.exit).toHaveBeenCalledWith(0);
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(spawn).not.toHaveBeenCalled();
  });

  it("post-core resume mode uses the parent install records snapshot for missing payload warnings", async () => {
    const resultDir = createCaseDir("openclaw-post-core-records");
    const recordsPath = path.join(resultDir, "plugin-install-records.json");
    const installPath = path.join(resultDir, "demo-plugin");
    await fs.mkdir(installPath, { recursive: true });
    await fs.writeFile(
      recordsPath,
      `${JSON.stringify({
        demo: {
          source: "npm",
          spec: "@openclaw/demo@1.0.0",
          installPath,
        },
      })}\n`,
      "utf-8",
    );
    pathExists.mockImplementation(async (candidate: string) => candidate === installPath);

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH: recordsPath,
      },
      async () => {
        await updateCommand({ json: true, restart: false });
      },
    );

    const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
    expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
    expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
      "package.json is missing",
    );
    const updateCall = lastNpmPluginUpdateCall() as { skipIds?: Set<string> } | undefined;
    expect(updateCall?.skipIds?.has("demo")).toBe(true);
  });

  it("post-core resume mode prefers post-doctor disk install records over the stale parent snapshot", async () => {
    const resultDir = createCaseDir("openclaw-post-core-disk-records");
    const recordsPath = path.join(resultDir, "plugin-install-records.json");
    await fs.mkdir(resultDir, { recursive: true });
    await fs.writeFile(
      recordsPath,
      `${JSON.stringify({
        stale: {
          source: "npm",
          spec: "@openclaw/stale@1.0.0",
          installPath: "/tmp/stale-plugin",
        },
      })}\n`,
      "utf-8",
    );
    const postDoctorRecords = {
      codex: {
        source: "npm",
        spec: "@openclaw/codex@2026.5.17",
        installPath: "/tmp/codex-plugin",
      },
    } satisfies Record<string, PluginInstallRecord>;
    loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords);

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH: recordsPath,
      },
      async () => {
        await updateCommand({ json: true, restart: false });
      },
    );

    expect(syncPluginCall()?.config?.plugins?.installs).toEqual(postDoctorRecords);
  });

  it("post-core resume mode persists the requested update channel with the updated process", async () => {
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      parsed: { update: { channel: "stable" } },
      resolved: { update: { channel: "stable" } } as OpenClawConfig,
      sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
      runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
      config: { update: { channel: "stable" } } as OpenClawConfig,
      hash: "stable-hash",
    });

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
        OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
      },
      async () => {
        await updateCommand({ restart: false });
      },
    );

    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(replaceConfigFile).toHaveBeenCalledWith({
      nextConfig: {
        update: {
          channel: "dev",
        },
      },
      baseHash: "stable-hash",
    });
    expect(mutateConfigFileWithRetry).toHaveBeenCalledWith(
      expect.objectContaining({
        writeOptions: { skipPluginValidation: true },
      }),
    );
    expect(syncPluginCall()?.channel).toBe("dev");
    expect(syncPluginCall()?.config?.update?.channel).toBe("dev");
  });

  it("post-core resume mode retries update channel persistence after config hash drift", async () => {
    vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
      ...baseSnapshot,
      parsed: { update: { channel: "stable" } },
      resolved: { update: { channel: "stable" } } as OpenClawConfig,
      sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
      runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
      config: { update: { channel: "stable" } } as OpenClawConfig,
      hash: "stable-hash",
    });
    const newerSnapshot = {
      ...baseSnapshot,
      parsed: {
        meta: { lastTouchedVersion: "2026.4.30" },
        update: { channel: "stable" },
      },
      resolved: {
        meta: { lastTouchedVersion: "2026.4.30" },
        update: { channel: "stable" },
      } as OpenClawConfig,
      sourceConfig: {
        meta: { lastTouchedVersion: "2026.4.30" },
        update: { channel: "stable" },
      } as OpenClawConfig,
      runtimeConfig: {
        meta: { lastTouchedVersion: "2026.4.30" },
        update: { channel: "stable" },
      } as OpenClawConfig,
      config: {
        meta: { lastTouchedVersion: "2026.4.30" },
        update: { channel: "stable" },
      } as OpenClawConfig,
      hash: "newer-hash",
    };
    vi.mocked(mutateConfigFileWithRetry).mockImplementationOnce(async (params) => {
      const nextConfig = structuredClone(newerSnapshot.sourceConfig);
      await params.mutate(nextConfig, {
        snapshot: newerSnapshot,
        previousHash: newerSnapshot.hash,
        attempt: 1,
      });
      return {
        path: newerSnapshot.path,
        previousHash: newerSnapshot.hash,
        snapshot: newerSnapshot,
        nextConfig,
        persistedHash: newerSnapshot.hash,
        result: undefined,
        attempts: 2,
        afterWrite: { mode: "none", reason: "test" },
        followUp: { mode: "none", reason: "test", requiresRestart: false },
      };
    });

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
        OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
      },
      async () => {
        await updateCommand({ restart: false });
      },
    );

    expect(mutateConfigFileWithRetry).toHaveBeenCalledTimes(1);
    expect(syncPluginCall()?.config?.meta?.lastTouchedVersion).toBe("2026.4.30");
    expect(syncPluginCall()?.config?.update?.channel).toBe("dev");
  });

  it("passes the update timeout budget into post-core plugin updates", async () => {
    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ restart: false, timeout: "1800" });
      },
    );

    expect(npmPluginUpdateCall()?.timeoutMs).toBe(1_800_000);
  });

  it("uses a fail-closed integrity policy for post-core plugin updates", async () => {
    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ restart: false });
      },
    );

    const updateCall = npmPluginUpdateCall() as
      | {
          onIntegrityDrift?: (drift: {
            pluginId: string;
            spec: string;
            expectedIntegrity: string;
            actualIntegrity: string;
            resolvedSpec?: string;
          }) => Promise<boolean>;
        }
      | undefined;
    const onIntegrityDrift = updateCall?.onIntegrityDrift;
    expect(onIntegrityDrift).toBeTypeOf("function");
    if (!onIntegrityDrift) {
      throw new Error("missing integrity drift handler");
    }

    vi.mocked(runtimeCapture.log).mockClear();
    await expect(
      onIntegrityDrift({
        pluginId: "demo",
        spec: "@openclaw/demo@1.0.0",
        resolvedSpec: "@openclaw/demo@1.0.0",
        expectedIntegrity: "sha512-old",
        actualIntegrity: "sha512-new",
      }),
    ).resolves.toBe(false);
    const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0]));
    expect(logs.join("\n")).toContain("Plugin update aborted");
  });

  it("keeps json update output successful when post-core plugin updates warn", async () => {
    updateNpmInstalledPlugins.mockImplementationOnce(
      async (params: {
        config: OpenClawConfig;
        onIntegrityDrift?: (drift: {
          pluginId: string;
          spec: string;
          resolvedSpec?: string;
          resolvedVersion?: string;
          expectedIntegrity: string;
          actualIntegrity: string;
          dryRun: boolean;
        }) => Promise<boolean>;
      }) => {
        const proceed = await params.onIntegrityDrift?.({
          pluginId: "demo",
          spec: "@openclaw/demo@1.0.0",
          resolvedSpec: "@openclaw/demo@1.0.0",
          resolvedVersion: "1.0.0",
          expectedIntegrity: "sha512-old",
          actualIntegrity: "sha512-new",
          dryRun: false,
        });
        return {
          changed: false,
          config: params.config,
          outcomes: [
            {
              pluginId: "demo",
              status: "error",
              message:
                proceed === false
                  ? "Failed to update demo: aborted: npm package integrity drift detected for @openclaw/demo@1.0.0"
                  : "unexpected drift continuation",
            },
          ],
        };
      },
    );
    vi.mocked(defaultRuntime.writeJson).mockClear();

    await updateCommand({ json: true, restart: false });

    const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
    expect(jsonOutput?.status).toBe("ok");
    expect(jsonOutput?.reason).toBeUndefined();
    expect(jsonOutput?.postUpdate?.plugins?.integrityDrifts).toEqual([
      {
        pluginId: "demo",
        spec: "@openclaw/demo@1.0.0",
        resolvedSpec: "@openclaw/demo@1.0.0",
        resolvedVersion: "1.0.0",
        expectedIntegrity: "sha512-old",
        actualIntegrity: "sha512-new",
        action: "aborted",
      },
    ]);
    expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
    expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo");
    expect(pluginWarning(jsonOutput)?.guidance).toEqual([
      "Run openclaw doctor --fix to attempt automatic repair.",
      "Run openclaw plugins inspect demo --runtime --json for details.",
    ]);
    expect(pluginWarning(jsonOutput)?.reason).toContain("npm package integrity drift");
    expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error");
    expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
      "Run openclaw doctor --fix to attempt automatic repair.",
    );
    expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
      "Run openclaw plugins inspect demo --runtime --json for details.",
    );
  });

  it("detects missing plugin payloads from persisted records before npm updates", async () => {
    const installPath = createCaseDir("openclaw-missing-plugin-payload");
    fsSync.mkdirSync(installPath, { recursive: true });
    const config = {
      plugins: {
        entries: {
          demo: { enabled: true },
        },
      },
    } as OpenClawConfig;
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      parsed: config,
      resolved: config,
      sourceConfig: config,
      config,
      runtimeConfig: config,
    });
    loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce({
      demo: {
        source: "npm",
        spec: "@openclaw/demo@1.0.0",
        installPath,
      },
    });
    syncPluginsForUpdateChannel.mockResolvedValueOnce({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    });
    pathExists.mockImplementation(async (candidate: string) => candidate === installPath);
    vi.mocked(defaultRuntime.writeJson).mockClear();

    await updateCommand({ json: true, restart: false });

    const updateCall = lastNpmPluginUpdateCall() as { skipIds?: Set<string> } | undefined;
    expect(updateCall?.skipIds?.has("demo")).toBe(true);
    const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
    expect(jsonOutput?.status).toBe("ok");
    expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
    expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo");
    expect(pluginWarning(jsonOutput)?.reason).toContain("package.json is missing");
    expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo");
    expect(pluginOutcome(jsonOutput)?.status).toBe("error");
  });

  it("prints non-fatal plugin warnings in human update output", async () => {
    updateNpmInstalledPlugins.mockResolvedValueOnce({
      changed: false,
      config: baseConfig,
      outcomes: [
        {
          pluginId: "demo",
          status: "error",
          message: "Failed to update demo: registry timeout",
        },
      ],
    });

    await updateCommand({ yes: true, restart: false });

    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
    expect(runDaemonInstall).not.toHaveBeenCalled();
    expect(runDaemonRestart).not.toHaveBeenCalled();
    expect(runRestartScript).not.toHaveBeenCalled();
    expect(
      vi
        .mocked(defaultRuntime.error)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).not.toContain("Update failed during plugin post-update sync.");
    const logs = vi
      .mocked(defaultRuntime.log)
      .mock.calls.map((call) => String(call[0]))
      .join("\n");
    expect(logs).toContain("Failed to update demo: registry timeout");
    expect(logs).toContain("Run openclaw doctor --fix to attempt automatic repair.");
    expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details.");
  });

  it("marks disabled-after-failure plugin skips as post-update warnings", async () => {
    updateNpmInstalledPlugins.mockResolvedValueOnce({
      changed: true,
      config: baseConfig,
      outcomes: [
        {
          pluginId: "demo",
          status: "skipped",
          message:
            'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout',
        },
      ],
    });
    vi.mocked(defaultRuntime.writeJson).mockClear();

    await updateCommand({ json: true, restart: false });

    const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
    expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
    expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo");
    expect(pluginWarning(jsonOutput)?.guidance).toEqual([
      "Run openclaw doctor --fix to attempt automatic repair.",
      "Run openclaw plugins inspect demo --runtime --json for details.",
    ]);
    expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo");
    expect(pluginOutcome(jsonOutput)?.status).toBe("skipped");
  });

  it("fails unexpected post-core plugin sync exceptions", async () => {
    syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke"));

    await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
      "plugin sync invariant broke",
    );
  });

  it("fails unexpected post-core npm update exceptions", async () => {
    updateNpmInstalledPlugins.mockRejectedValueOnce(new Error("npm update invariant broke"));

    await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
      "npm update invariant broke",
    );
  });

  it("preserves fresh-process plugin warning details in parent json output", async () => {
    setupUpdatedRootRefresh();
    spawn.mockImplementationOnce((_node, _argv, options) => {
      const child = new EventEmitter() as EventEmitter & {
        once: EventEmitter["once"];
      };
      const env = (options as { env?: NodeJS.ProcessEnv }).env;
      queueMicrotask(async () => {
        const resultPath = env?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
        if (resultPath) {
          await fs.writeFile(
            resultPath,
            JSON.stringify({
              status: "warning",
              changed: false,
              warnings: [
                {
                  pluginId: "demo",
                  reason: "Failed to update demo: registry timeout",
                  message:
                    'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
                  guidance: [
                    "Run openclaw doctor --fix to attempt automatic repair.",
                    "Run openclaw plugins inspect demo --runtime --json for details.",
                  ],
                },
              ],
              sync: {
                changed: false,
                switchedToBundled: [],
                switchedToNpm: [],
                warnings: [],
                errors: [],
              },
              npm: {
                changed: false,
                outcomes: [
                  {
                    pluginId: "demo",
                    status: "error",
                    message: "Failed to update demo: registry timeout",
                  },
                ],
              },
              integrityDrifts: [],
            }),
            "utf-8",
          );
        }
        child.emit("exit", 0, null);
      });
      return child;
    });
    vi.mocked(defaultRuntime.writeJson).mockClear();

    await updateCommand({ yes: true, json: true, restart: false });

    const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
    expect(jsonOutput?.status).toBe("ok");
    expect(jsonOutput?.reason).toBeUndefined();
    expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.guidance).toContain(
      "Run openclaw doctor --fix to attempt automatic repair.",
    );
    expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain("registry timeout");
  });

  it.each([
    {
      name: "preview mode",
      run: async () => {
        vi.mocked(defaultRuntime.log).mockClear();
        serviceLoaded.mockResolvedValue(true);
        await updateCommand({ dryRun: true, channel: "beta" });
      },
      assert: () => {
        expect(replaceConfigFile).not.toHaveBeenCalled();
        expect(runGatewayUpdate).not.toHaveBeenCalled();
        expect(runDaemonInstall).not.toHaveBeenCalled();
        expect(runRestartScript).not.toHaveBeenCalled();
        expect(runDaemonRestart).not.toHaveBeenCalled();
        expect(
          launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob,
        ).not.toHaveBeenCalled();

        const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
        expect(logs.join("\n")).toContain("Update dry-run");
        expect(logs.join("\n")).toContain("No changes were applied.");
      },
    },
    {
      name: "downgrade bypass",
      run: async () => {
        await setupNonInteractiveDowngrade();
        vi.mocked(defaultRuntime.exit).mockClear();
        await updateCommand({ dryRun: true });
      },
      assert: () => {
        expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
        expect(runGatewayUpdate).not.toHaveBeenCalled();
        expect(
          launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob,
        ).not.toHaveBeenCalled();
      },
    },
  ] as const)("updateCommand dry-run behavior: $name", runUpdateCliScenario);

  it.each([
    {
      name: "table output",
      run: async () => {
        vi.mocked(defaultRuntime.log).mockClear();
        await updateStatusCommand({ json: false });
      },
      assert: () => {
        const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]);
        expect(logs.join("\n")).toContain("OpenClaw update status");
      },
    },
    {
      name: "json output",
      run: async () => {
        vi.mocked(defaultRuntime.log).mockClear();
        await updateStatusCommand({ json: true });
      },
      assert: () => {
        const last = requireValue(lastWriteJsonCall(), "update status JSON output");
        const parsed = last as Record<string, unknown>;
        const channel = parsed.channel as { value?: unknown };
        expect(channel.value).toBe(isBetaTag(VERSION) ? "beta" : "stable");
      },
    },
  ] as const)("updateStatusCommand rendering: $name", runUpdateCliScenario);

  it("renders update status when unrelated config validation would fail", async () => {
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      valid: false,
      config: {} as OpenClawConfig,
    });
    vi.mocked(readSourceConfigBestEffort).mockResolvedValue({
      update: { channel: "dev" },
    } as OpenClawConfig);

    await updateStatusCommand({ json: true });

    const last = requireValue(lastWriteJsonCall(), "update status JSON output");
    const parsed = last as Record<string, unknown>;
    const channel = parsed.channel as { value?: unknown; config?: unknown };
    expect(channel.value).toBe("dev");
    expect(channel.config).toBe("dev");
  });

  it("parses update status --json as the subcommand option", async () => {
    const program = new Command();
    program.name("openclaw");
    program.enablePositionalOptions();
    let seenJson = false;
    const update = program.command("update").option("--json", "", false);
    update
      .command("status")
      .option("--json", "", false)
      .action((opts) => {
        seenJson = Boolean(opts.json);
      });

    await program.parseAsync(["node", "openclaw", "update", "status", "--json"]);

    expect(seenJson).toBe(true);
  });

  it.each([
    {
      name: "defaults to dev channel for git installs when unset",
      mode: "git" as const,
      options: {},
      prepare: async () => {},
      expectedChannel: "dev" as const,
      expectedTag: undefined as string | undefined,
    },
    {
      name: "defaults to stable channel for package installs when unset",
      options: { yes: true },
      prepare: async () => {
        const tempDir = createCaseDir("openclaw-update");
        mockPackageInstallStatus(tempDir);
      },
      expectedChannel: undefined as "stable" | undefined,
      expectedTag: undefined as string | undefined,
    },
    {
      name: "uses stored beta channel when configured",
      mode: "git" as const,
      options: {},
      prepare: async () => {
        vi.mocked(readConfigFileSnapshot).mockResolvedValue({
          ...baseSnapshot,
          config: { update: { channel: "beta" } } as OpenClawConfig,
        });
      },
      expectedChannel: "beta" as const,
      expectedTag: undefined as string | undefined,
    },
    {
      name: "switches git installs to package mode for explicit beta and persists it",
      mode: "git" as const,
      options: { channel: "beta" },
      prepare: async () => {},
      expectedChannel: undefined as string | undefined,
      expectedTag: undefined as string | undefined,
      expectedPersistedChannel: "beta" as const,
    },
  ])(
    "$name",
    async ({ mode, options, prepare, expectedChannel, expectedTag, expectedPersistedChannel }) => {
      await prepare();
      if (mode) {
        vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
      }

      await updateCommand(options);

      if (expectedChannel !== undefined) {
        const call = expectUpdateCallChannel(expectedChannel);
        if (expectedTag !== undefined) {
          expect(call?.tag).toBe(expectedTag);
        }
      } else {
        expectPackageInstallSpec("openclaw@latest");
      }

      if (expectedPersistedChannel !== undefined) {
        expect(replaceConfigFile).toHaveBeenCalledTimes(1);
        const writeCall = replaceConfigCall() as
          | { nextConfig?: { update?: { channel?: string } } }
          | undefined;
        expect(writeCall?.nextConfig?.update?.channel).toBe(expectedPersistedChannel);
      }
    },
  );

  it("falls back to latest when beta tag is older than release", async () => {
    const tempDir = createCaseDir("openclaw-update");

    mockPackageInstallStatus(tempDir);
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      config: { update: { channel: "beta" } } as OpenClawConfig,
    });
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "1.2.3-1",
    });
    await updateCommand({});

    expectPackageInstallSpec("openclaw@latest");
  });

  it("refreshes package-manager updates when the installed version already matches the target", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    readPackageVersion.mockResolvedValue("2026.4.22");
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "2026.4.22",
    });

    await updateCommand({ yes: true });

    const installCalls = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.filter(
        ([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g",
      );
    expect(installCalls).toHaveLength(1);
    expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
    expect(replaceConfigFile).not.toHaveBeenCalled();
    const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
    expect(logs.join("\n")).not.toContain("already-current");
  });

  it("warns but still runs package updates when disk space looks low", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    vi.spyOn(fsSync, "statfsSync").mockReturnValue(
      statfsFixture({
        bavail: 256,
        bsize: 1024 * 1024,
      }),
    );

    await updateCommand({ yes: true });

    expectPackageInstallSpec("openclaw@latest");
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
    expect(
      vi
        .mocked(defaultRuntime.log)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).toContain("Low disk space near");
  });

  it("allows package updates from inherited gateway service env when the managed gateway is not running", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    serviceReadRuntime.mockResolvedValueOnce({
      status: "stopped",
      state: "stopped",
    });

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
      async () => {
        await updateCommand({ yes: true });
      },
    );

    expect(defaultRuntime.error).not.toHaveBeenCalledWith(
      [
        "Package updates cannot run from inside the gateway service process.",
        "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
        "Run `openclaw update` from a shell outside the gateway service, or stop the gateway service first and then update.",
      ].join("\n"),
    );
    expectPackageInstallSpec("openclaw@latest");
  });

  it("refuses package updates from inherited gateway service env when --no-restart leaves the gateway running", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    serviceReadCommand.mockResolvedValue({
      programArguments: ["openclaw", "gateway", "run"],
      environment: {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
    });
    serviceLoaded.mockResolvedValue(true);

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    expect(defaultRuntime.error).toHaveBeenCalledWith(
      [
        "Package updates cannot run from inside the gateway service process.",
        "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
        "Run `openclaw update` from a shell outside the gateway service, or stop the gateway service first and then update.",
      ].join("\n"),
    );
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(serviceStop).not.toHaveBeenCalled();
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(packageInstallCommandCall()).toBeUndefined();
  });

  it.each([
    {
      name: "runtime probe fails",
      setupRuntime: () =>
        serviceReadRuntime.mockRejectedValueOnce(new Error("runtime probe failed")),
    },
    {
      name: "runtime status is unknown",
      setupRuntime: () => serviceReadRuntime.mockResolvedValueOnce({ status: "unknown" }),
    },
  ])(
    "refuses package updates from inherited gateway service env when $name",
    async ({ setupRuntime }) => {
      mockPackageInstallStatus(createCaseDir("openclaw-update"));
      serviceReadCommand.mockResolvedValue({
        programArguments: ["openclaw", "gateway", "run"],
        environment: {
          OPENCLAW_SERVICE_MARKER: "openclaw",
          OPENCLAW_SERVICE_KIND: "gateway",
        },
      });
      setupRuntime();

      await withEnvAsync(
        {
          OPENCLAW_SERVICE_MARKER: "openclaw",
          OPENCLAW_SERVICE_KIND: "gateway",
        },
        async () => {
          await updateCommand({ yes: true });
        },
      );

      expect(defaultRuntime.error).toHaveBeenCalledWith(
        [
          "Package updates cannot run from inside the gateway service process.",
          "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
          "Run `openclaw update` from a shell outside the gateway service, or stop the gateway service first and then update.",
        ].join("\n"),
      );
      expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
      expect(serviceStop).not.toHaveBeenCalled();
      expect(runGatewayUpdate).not.toHaveBeenCalled();
      expect(packageInstallCommandCall()).toBeUndefined();
    },
  );

  it("refuses package updates from inherited gateway service env when the service definition is missing but runtime is live", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    serviceReadCommand.mockResolvedValue(null);
    serviceReadRuntime.mockResolvedValueOnce({
      status: "running",
      pid: 4242,
      state: "running",
    });

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
      async () => {
        await updateCommand({ yes: true });
      },
    );

    expect(defaultRuntime.error).toHaveBeenCalledWith(
      [
        "Package updates cannot run from inside the gateway service process.",
        "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
        "Run `openclaw update` from a shell outside the gateway service, or stop the gateway service first and then update.",
      ].join("\n"),
    );
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(serviceStop).not.toHaveBeenCalled();
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(packageInstallCommandCall()).toBeUndefined();
  });

  it("refuses package updates from inside the active gateway process tree", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    serviceLoaded.mockResolvedValue(true);
    mockGetSelfAndAncestorPidsSync.mockReturnValue(new Set<number>([process.pid, 4242]));

    await updateCommand({ yes: true });

    const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
    expect(errors.join("\n")).toContain(
      "openclaw update detected it is running inside the gateway process tree.",
    );
    expect(errors.join("\n")).toContain("Gateway PID 4242 is an ancestor");
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(serviceStop).not.toHaveBeenCalled();
    expect(packageInstallCommandCall()).toBeUndefined();
  });

  it("refuses package updates from inherited gateway runtime pid when process ancestry is truncated", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    serviceLoaded.mockResolvedValue(true);
    serviceReadRuntime.mockResolvedValue({
      status: "running",
      pid: 4242,
      state: "running",
    });
    mockGetSelfAndAncestorPidsSync.mockReturnValue(new Set<number>([process.pid]));

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
        [GATEWAY_SERVICE_RUNTIME_PID_ENV]: "4242",
      },
      async () => {
        await updateCommand({ yes: true });
      },
    );

    const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
    expect(errors.join("\n")).toContain(
      "openclaw update detected it is running inside the gateway process tree.",
    );
    expect(errors.join("\n")).toContain("Gateway PID 4242 is an ancestor");
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(serviceStop).not.toHaveBeenCalled();
    expect(packageInstallCommandCall()).toBeUndefined();
  });

  it("blocks package updates when the target requires a newer Node runtime", async () => {
    mockPackageInstallStatus(createCaseDir("openclaw-update"));
    vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
      target: "latest",
      version: "2026.3.23-2",
      nodeEngine: ">=22.19.0",
    });
    nodeVersionSatisfiesEngine.mockReturnValue(false);

    await updateCommand({ yes: true });

    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(packageInstallCommandCall()).toBeUndefined();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
    expect(errors.join("\n")).toContain("Node ");
    expect(errors.join("\n")).toContain(
      "Bare `npm i -g openclaw` can silently install an older compatible release.",
    );
  });

  it.each([
    {
      name: "explicit dist-tag",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ tag: "next" });
      },
      expectedSpec: "openclaw@next",
    },
    {
      name: "main shorthand",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "main" });
      },
      expectedSpec: "github:openclaw/openclaw#main",
    },
    {
      name: "explicit git package spec",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" });
      },
      expectedSpec: "github:openclaw/openclaw#main",
    },
    {
      name: "aliased git package spec",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "OpenClaw@github:openclaw/openclaw#main" });
      },
      expectedSpec: "OpenClaw@github:openclaw/openclaw#main",
    },
    {
      name: "full git URL package spec",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "https://github.com/openclaw/openclaw.git#main" });
      },
      expectedSpec: "https://github.com/openclaw/openclaw.git#main",
    },
    {
      name: "hosted GitHub URL package spec without git suffix",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "https://github.com/openclaw/openclaw#main" });
      },
      expectedSpec: "https://github.com/openclaw/openclaw#main",
    },
    {
      name: "aliased hosted GitHub URL package spec without git suffix",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({
          yes: true,
          tag: "openclaw@https://github.com/openclaw/openclaw#main",
        });
      },
      expectedSpec: "https://github.com/openclaw/openclaw#main",
    },
    {
      name: "GitHub shorthand package spec",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "openclaw/openclaw#main" });
      },
      expectedSpec: "openclaw/openclaw#main",
    },
    {
      name: "SCP-style SSH package spec",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await updateCommand({ yes: true, tag: "git@github.com:openclaw/openclaw.git#main" });
      },
      expectedSpec: "git@github.com:openclaw/openclaw.git#main",
    },
    {
      name: "OPENCLAW_UPDATE_PACKAGE_SPEC override",
      run: async () => {
        mockPackageInstallStatus(createCaseDir("openclaw-update"));
        await withEnvAsync(
          { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" },
          async () => {
            await updateCommand({ yes: true, tag: "latest" });
          },
        );
      },
      expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz",
    },
  ] as const)(
    "resolves package install specs from tags and env overrides: $name",
    async ({ run, expectedSpec }) => {
      vi.clearAllMocks();
      readPackageName.mockResolvedValue("openclaw");
      readPackageVersion.mockResolvedValue("1.0.0");
      resolveGlobalManager.mockResolvedValue("npm");
      vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd());
      await run();
      expectPackageInstallSpec(expectedSpec);
    },
  );

  it("fails package updates when the installed correction version does not match the requested target", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    mockPackageInstallStatus(tempDir);
    await fs.mkdir(pkgRoot, { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.3.23" }),
      "utf-8",
    );
    for (const relativePath of TEST_BUNDLED_RUNTIME_SIDECAR_PATHS) {
      const absolutePath = path.join(pkgRoot, relativePath);
      await fs.mkdir(path.dirname(absolutePath), { recursive: true });
      await fs.writeFile(absolutePath, "export {};\n", "utf-8");
    }
    await writePackageDistInventory(pkgRoot);
    readPackageVersion.mockResolvedValue("2026.3.23");
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: nodeModules,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true, tag: "2026.3.23-2" });

    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(replaceConfigFile).not.toHaveBeenCalled();
    const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
    expect(logs.join("\n")).toContain("global install verify");
    expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23");
  });

  it("stops package post-update work when staged npm install verification fails", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-staged-fail-");
    const prefix = path.join(tempDir, "prefix");
    const nodeModules = path.join(prefix, "lib", "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    mockPackageInstallStatus(pkgRoot);
    readPackageVersion.mockResolvedValue("2026.4.20");
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "2026.4.25",
    });
    await fs.mkdir(path.join(pkgRoot, "dist"), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.20" }),
      "utf-8",
    );
    await fs.writeFile(path.join(pkgRoot, "dist", "index.js"), "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);

    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        argv[0] === "npm" &&
        argv[1] === "i" &&
        argv.includes("--prefix")
      ) {
        const stagePrefix = argv[argv.indexOf("--prefix") + 1];
        if (typeof stagePrefix !== "string") {
          throw new Error("missing stage prefix");
        }
        const stageRoot = path.join(stagePrefix, "lib", "node_modules", "openclaw");
        await fs.mkdir(path.join(stageRoot, "dist"), { recursive: true });
        await fs.writeFile(
          path.join(stageRoot, "package.json"),
          JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
          "utf-8",
        );
        await fs.writeFile(path.join(stageRoot, "dist", "index.js"), "export {};\n", "utf-8");
        await writePackageDistInventory(stageRoot);
        await fs.writeFile(
          path.join(stageRoot, "dist", "stale-runtime.js"),
          "export {};\n",
          "utf-8",
        );
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true, restart: false });

    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(doctorCommandCall()).toBeUndefined();
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
    await expect(fs.readFile(path.join(pkgRoot, "package.json"), "utf-8")).resolves.toContain(
      '"version":"2026.4.20"',
    );
    const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
    expect(logs.join("\n")).toContain("global install verify");
    expect(logs.join("\n")).toContain("unexpected packaged dist file dist/stale-runtime.js");
  });

  it("marks package post-update doctor as update-in-progress", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-package-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true });

    const doctorCall = doctorCommandCall();
    expect(doctorCall?.[0][0]).toContain("node");
    expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
    expect(
      (doctorCall?.[1].env as NodeJS.ProcessEnv | undefined)?.OPENCLAW_UPDATE_IN_PROGRESS,
    ).toBe("1");
  });

  it("runs package post-update doctor from the verified package root after a staged swap", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-staged-doctor-");
    const nodeModules = path.join(tempDir, "lib", "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (!Array.isArray(argv)) {
        return {
          stdout: "",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (argv[0] === "npm" && argv[1] === "i" && argv.includes("--prefix")) {
        const stagePrefix = argv[argv.indexOf("--prefix") + 1];
        const stagePackageRoot = path.join(
          requireValue(stagePrefix, "stage prefix"),
          "lib",
          "node_modules",
          "openclaw",
        );
        const stageEntryPath = path.join(stagePackageRoot, "dist", "index.js");
        await fs.mkdir(path.dirname(stageEntryPath), { recursive: true });
        await fs.writeFile(
          path.join(stagePackageRoot, "package.json"),
          JSON.stringify({ name: "openclaw", version: "2026.5.14" }),
          "utf-8",
        );
        await fs.writeFile(stageEntryPath, "export {};\n", "utf-8");
        await writePackageDistInventory(stagePackageRoot);
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });
    readPackageVersion.mockImplementation(async (packageRoot: string) => {
      const manifest = JSON.parse(
        await fs.readFile(path.join(packageRoot, "package.json"), "utf-8"),
      ) as { version?: string };
      return manifest.version ?? "0.0.0";
    });

    await updateCommand({ yes: true });

    const doctorCall = doctorCommandCall();
    expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
    expect(doctorCall?.[1].cwd).toBe(pkgRoot);
    expect(
      (doctorCall?.[1].env as NodeJS.ProcessEnv | undefined)?.OPENCLAW_COMPATIBILITY_HOST_VERSION,
    ).toBe("2026.5.14");
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("stops a running managed gateway before package replacement", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-stop-service-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: ["openclaw", "gateway", "run"],
      environment: {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
    });
    serviceLoaded.mockResolvedValue(true);
    serviceReadRuntime.mockResolvedValue({
      status: "running",
      pid: 4242,
      state: "running",
    });
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await withEnvAsync(
      {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
      async () => {
        await updateCommand({ yes: true });
      },
    );

    const npmInstallCallIndex = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.findIndex(
        (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "i",
      );
    const npmInstallCallOrder =
      vi.mocked(runCommandWithTimeout).mock.invocationCallOrder[npmInstallCallIndex];
    const serviceStopCall = serviceStop.mock.calls[0]?.[0] as
      | { env?: NodeJS.ProcessEnv }
      | undefined;
    expect(serviceStopCall?.env?.OPENCLAW_SERVICE_MARKER).toBe("openclaw");
    expect(serviceStopCall?.env?.OPENCLAW_SERVICE_KIND).toBe("gateway");
    const serviceStopCallOrder = serviceStop.mock.invocationCallOrder[0];
    const requiredServiceStopCallOrder = requireValue(
      serviceStopCallOrder,
      "service stop call order",
    );
    const requiredNpmInstallCallOrder = requireValue(npmInstallCallOrder, "npm install call order");
    expect(requiredServiceStopCallOrder).toBeLessThan(requiredNpmInstallCallOrder);
  });

  it("keeps managed service stop output off stdout during json package updates", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-json-stop-service-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: ["openclaw", "gateway", "run"],
      environment: {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
    });
    serviceLoaded.mockResolvedValue(true);
    serviceReadRuntime.mockResolvedValue({
      status: "running",
      pid: 4242,
      state: "running",
    });
    serviceStop.mockImplementationOnce(async (params: { stdout?: NodeJS.WritableStream }) => {
      params.stdout?.write("Stopped systemd service: openclaw-gateway.service\n");
    });
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    let writes = "";
    try {
      await updateCommand({ yes: true, json: true });
      writes = stdoutWrite.mock.calls.map((call) => String(call[0])).join("\n");
    } finally {
      stdoutWrite.mockRestore();
    }

    expect(writes).not.toContain("Stopped systemd service");
    expect(serviceStop).toHaveBeenCalled();
  });

  it("disarms legacy launchd updater jobs before stopping the gateway", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-launchd-loop-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    await writePackageDistInventory(pkgRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: ["openclaw", "gateway", "run"],
      environment: {
        OPENCLAW_SERVICE_MARKER: "openclaw",
        OPENCLAW_SERVICE_KIND: "gateway",
      },
    });
    serviceLoaded.mockResolvedValue(true);
    serviceReadRuntime.mockResolvedValue({
      status: "running",
      pid: 4242,
      state: "running",
    });
    launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob.mockResolvedValue(true);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true });

    const cleanupOrder =
      launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob.mock.invocationCallOrder[0];
    const serviceStopOrder = serviceStop.mock.invocationCallOrder[0];
    expect(requireValue(cleanupOrder, "launchd updater cleanup order")).toBeLessThan(
      requireValue(serviceStopOrder, "service stop order"),
    );
  });

  it("refreshes package installs even when the current version already matches the target", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-current-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    const entryPath = path.join(pkgRoot, "dist", "index.js");
    mockPackageInstallStatus(pkgRoot);
    readPackageVersion.mockResolvedValue("2026.4.23");
    vi.mocked(resolveNpmChannelTag).mockResolvedValue({
      tag: "latest",
      version: "2026.4.23",
    });
    await fs.mkdir(path.dirname(entryPath), { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.4.23" }),
      "utf-8",
    );
    await fs.writeFile(entryPath, "export {};\n", "utf-8");
    for (const relativePath of TEST_BUNDLED_RUNTIME_SIDECAR_PATHS) {
      const absolutePath = path.join(pkgRoot, relativePath);
      await fs.mkdir(path.dirname(absolutePath), { recursive: true });
      await fs.writeFile(absolutePath, "export {};\n", "utf-8");
    }
    await writePackageDistInventory(pkgRoot);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true, restart: false });

    expectPackageInstallSpec("openclaw@latest");
    const doctorCall = doctorCommandCall();
    expect(doctorCall?.[0][0]).toContain("node");
    expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
    const postCoreSpawn = spawnCall();
    expect(postCoreSpawn?.[0]).toContain("node");
    expect(postCoreSpawn?.[1]).toEqual([entryPath, "update", "--no-restart", "--yes"]);
    expect(postCoreSpawn?.[2].stdio).toBe("inherit");
    expect(postCoreSpawn?.[2].env?.OPENCLAW_UPDATE_POST_CORE).toBe("1");
    expect(postCoreSpawn?.[2].env?.OPENCLAW_UPDATE_POST_CORE_CHANNEL).toBe("stable");
    expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
    expect(
      vi
        .mocked(defaultRuntime.log)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).not.toContain("already-current");
  });

  it("retries package updates without optional deps when npm global update fails", async () => {
    const tempDir = await createTrackedTempDir("openclaw-update-optional-");
    const nodeModules = path.join(tempDir, "node_modules");
    const pkgRoot = path.join(nodeModules, "openclaw");
    mockPackageInstallStatus(pkgRoot);
    await fs.mkdir(pkgRoot, { recursive: true });
    await fs.writeFile(
      path.join(pkgRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "1.0.0" }),
      "utf-8",
    );

    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        argv[0] === "npm" &&
        argv.includes("-g") &&
        !argv.includes("--omit=optional")
      ) {
        return {
          stdout: "",
          stderr: "node-gyp failed",
          code: 1,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true, restart: false });

    const installArgvs = commandCalls()
      .map(([argv]) => argv)
      .filter((argv) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g");
    expect(installArgvs).toEqual([
      [
        "npm",
        "i",
        "-g",
        "openclaw@latest",
        "--no-fund",
        "--no-audit",
        "--loglevel=error",
        "--min-release-age=0",
      ],
      [
        "npm",
        "i",
        "-g",
        "openclaw@latest",
        "--omit=optional",
        "--no-fund",
        "--no-audit",
        "--loglevel=error",
        "--min-release-age=0",
      ],
    ]);
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => {
    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    const brewPrefix = createCaseDir("brew-prefix");
    const brewRoot = path.join(brewPrefix, "lib", "node_modules");
    const pkgRoot = path.join(brewRoot, "openclaw");
    const brewNpm = path.join(brewPrefix, "bin", "npm");
    const win32PrefixNpm = path.join(brewPrefix, "npm.cmd");
    const pathNpmRoot = createCaseDir("nvm-root");
    mockPackageInstallStatus(pkgRoot);
    pathExists.mockResolvedValue(false);

    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (!Array.isArray(argv)) {
        return {
          stdout: "",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${pathNpmRoot}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (isOwningNpmCommand(argv[0], brewPrefix) && argv[1] === "root" && argv[2] === "-g") {
        return {
          stdout: `${brewRoot}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await fs.mkdir(path.dirname(brewNpm), { recursive: true });
    await fs.writeFile(brewNpm, "", "utf8");
    await fs.writeFile(win32PrefixNpm, "", "utf8");
    await updateCommand({ yes: true });

    platformSpy.mockRestore();

    expect(runGatewayUpdate).not.toHaveBeenCalled();
    const installCall = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.find(
        ([argv]) =>
          Array.isArray(argv) &&
          isOwningNpmCommand(argv[0], brewPrefix) &&
          argv[1] === "i" &&
          argv[2] === "-g" &&
          argv.includes("openclaw@latest"),
      );

    const requiredInstallCall = requireValue(installCall, "brew npm install call");
    const installCommand = requiredInstallCall[0][0] ?? "";
    expect(installCommand).not.toBe("npm");
    expect(path.isAbsolute(installCommand)).toBe(true);
    expect(path.normalize(installCommand)).toContain(path.normalize(brewPrefix));
    expect(path.normalize(installCommand)).toMatch(
      new RegExp(
        `${path
          .normalize(path.join(brewPrefix, path.sep))
          .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*npm(?:\\.cmd)?$`,
        "i",
      ),
    );
    const installOptions = requiredInstallCall[1] as { timeoutMs?: number };
    expect(typeof installOptions.timeoutMs).toBe("number");
  });

  it("prepends portable Git PATH for package updates on Windows", async () => {
    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    const tempDir = createCaseDir("openclaw-update");
    const localAppData = createCaseDir("openclaw-localappdata");
    const portableGitMingw = path.join(
      localAppData,
      "OpenClaw",
      "deps",
      "portable-git",
      "mingw64",
      "bin",
    );
    const portableGitUsr = path.join(
      localAppData,
      "OpenClaw",
      "deps",
      "portable-git",
      "usr",
      "bin",
    );
    await fs.mkdir(portableGitMingw, { recursive: true });
    await fs.mkdir(portableGitUsr, { recursive: true });
    mockPackageInstallStatus(tempDir);
    pathExists.mockImplementation(
      async (candidate: string) => candidate === portableGitMingw || candidate === portableGitUsr,
    );

    await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
      await updateCommand({ yes: true });
    });

    platformSpy.mockRestore();

    const updateCall = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.find(
        (call) =>
          Array.isArray(call[0]) &&
          call[0][0] === "npm" &&
          call[0][1] === "i" &&
          call[0][2] === "-g",
      );
    const updateOptions =
      typeof updateCall?.[1] === "object" && updateCall[1] !== null ? updateCall[1] : undefined;
    const mergedPath = updateOptions?.env?.Path ?? updateOptions?.env?.PATH ?? "";
    expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
      portableGitMingw,
      portableGitUsr,
    ]);
    expect(updateOptions?.env?.NPM_CONFIG_SCRIPT_SHELL).toBeUndefined();
    expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
  });

  it.each([
    {
      name: "outputs JSON when --json is set",
      run: async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
        vi.mocked(defaultRuntime.writeJson).mockClear();
        await updateCommand({ json: true });
      },
      assert: () => {
        requireValue(lastWriteJsonCall(), "update JSON output");
      },
    },
    {
      name: "exits with error on failure",
      run: async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue({
          status: "error",
          mode: "git",
          reason: "rebase-failed",
          steps: [],
          durationMs: 100,
        } satisfies UpdateRunResult);
        vi.mocked(defaultRuntime.exit).mockClear();
        await updateCommand({});
      },
      assert: () => {
        expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
      },
    },
  ] as const)("updateCommand reports outcomes: $name", runUpdateCliScenario);

  it("persists the requested channel only after a successful package update", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);

    await updateCommand({ channel: "beta", yes: true });

    const installCallIndex = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.findIndex(
        (call) =>
          Array.isArray(call[0]) &&
          call[0][0] === "npm" &&
          call[0][1] === "i" &&
          call[0][2] === "-g",
      );
    expect(installCallIndex).toBeGreaterThanOrEqual(0);
    expect(replaceConfigFile).toHaveBeenCalledTimes(1);
    expect(replaceConfigFile).toHaveBeenCalledWith({
      nextConfig: {
        update: {
          channel: "beta",
        },
      },
      baseHash: undefined,
    });
    expect(
      vi.mocked(runCommandWithTimeout).mock.invocationCallOrder[installCallIndex] ?? 0,
    ).toBeLessThan(
      vi.mocked(replaceConfigFile).mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
    );
  });

  it("warns when a package update targets a managed service root outside the shell root", async () => {
    const shellRoot = createCaseDir("openclaw-shell-root");
    const serviceRoot = await createTrackedTempDir("openclaw-service-root-");
    const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node");
    await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true });
    await fs.writeFile(
      path.join(serviceRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
      "utf-8",
    );
    mockPackageInstallStatus(shellRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
    });

    await updateCommand({ dryRun: true });

    const logs = vi
      .mocked(defaultRuntime.log)
      .mock.calls.map((call) => String(call[0]))
      .join("\n");
    expect(logs).toContain(`Targeting managed gateway service package root: ${serviceRoot}`);
    expect(logs).toContain(
      `Shell OpenClaw root differs from the managed gateway service root: ${shellRoot}`,
    );
    expect(logs).toContain("make sure `openclaw` on PATH resolves to the managed service root");
    expect(logs).toContain(`Managed gateway service Node: ${serviceNode}`);
  });

  it("checks the managed service Node runtime before updating a redirected package root", async () => {
    const shellRoot = createCaseDir("openclaw-shell-root");
    const serviceRoot = await createTrackedTempDir("openclaw-service-root-");
    const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node");
    await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true });
    await fs.mkdir(path.dirname(serviceNode), { recursive: true });
    await fs.writeFile(serviceNode, "", "utf-8");
    await fs.writeFile(
      path.join(serviceRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
      "utf-8",
    );
    mockPackageInstallStatus(shellRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
    });
    vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
      target: "latest",
      version: "2026.5.20",
      nodeEngine: ">=22.19.0",
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
        return {
          stdout: "v22.18.0\n",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });
    nodeVersionSatisfiesEngine.mockReturnValue(false);

    await updateCommand({ yes: true });

    expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("22.18.0", ">=22.19.0");
    expect(packageInstallCommandCall()).toBeUndefined();
    expect(serviceStop).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
    expect(errors.join("\n")).toContain(`Node 22.18.0 at ${serviceNode} is too old`);
    expect(errors.join("\n")).toContain(
      "Upgrade the Node runtime that owns the managed Gateway service",
    );
  });

  it("runs managed service package follow-up commands with the service Node", async () => {
    const shellRoot = createCaseDir("openclaw-shell-root");
    const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-");
    const nodeModules = path.join(servicePrefix, "lib", "node_modules");
    const serviceRoot = path.join(nodeModules, "openclaw");
    const serviceNode = path.join(servicePrefix, "bin", "node");
    const serviceNpm = path.join(servicePrefix, "bin", "npm");
    const entrypoint = path.join(serviceRoot, "dist", "index.js");
    await fs.mkdir(path.dirname(entrypoint), { recursive: true });
    await fs.mkdir(path.dirname(serviceNode), { recursive: true });
    await fs.writeFile(serviceNode, "", "utf-8");
    await fs.writeFile(serviceNpm, "", "utf-8");
    const serviceNpmReal = await fs.realpath(serviceNpm);
    await fs.writeFile(
      path.join(serviceRoot, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
      "utf-8",
    );
    await fs.writeFile(entrypoint, "", "utf-8");
    await writePackageDistInventory(serviceRoot);
    mockPackageInstallStatus(shellRoot);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, entrypoint, "gateway"],
    });
    serviceLoaded.mockResolvedValue(true);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
        return {
          stdout: "v22.22.0\n",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        (argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
        argv[1] === "root" &&
        argv[2] === "-g"
      ) {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        (argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
        argv[1] === "i"
      ) {
        const stagePrefix = argv.includes("--prefix")
          ? argv[argv.indexOf("--prefix") + 1]
          : undefined;
        const stageRoot = stagePrefix
          ? path.join(stagePrefix, "lib", "node_modules", "openclaw")
          : serviceRoot;
        const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
        await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
        await fs.writeFile(
          path.join(stageRoot, "package.json"),
          JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
          "utf-8",
        );
        await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
        await writePackageDistInventory(stageRoot);
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true });

    expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
    expect(spawnCall()?.[0]).toBe(serviceNode);
    const serviceInstallCall = commandCalls().find(
      ([argv]) => argv[2] === "gateway" && argv[3] === "install",
    );
    expect(serviceInstallCall?.[0][0]).toBe(serviceNode);
  });

  it("uses the managed service Node when package roots match but node binaries differ", async () => {
    const root = createCaseDir("openclaw-same-root");
    // Service is baked with a different node than the current process.execPath.
    const serviceNode = "/opt/other-node/bin/node";
    const entrypoint = path.join(root, "dist", "index.js");
    mockPackageInstallStatus(root);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, entrypoint, "gateway"],
    });

    await updateCommand({ dryRun: true });

    const logs = vi
      .mocked(defaultRuntime.log)
      .mock.calls.map((call) => String(call[0]))
      .join("\n");
    // Should NOT log root redirect messages since the package root is the same.
    expect(logs).not.toContain("Targeting managed gateway service package root");
    // Should warn about the node binary mismatch.
    expect(logs).toContain("differs from the managed gateway service Node");
    expect(logs).toContain(serviceNode);
    expect(logs).toContain(
      "Using the managed service Node for this update so the gateway can start after the upgrade",
    );
  });

  it("uses the managed service Node for follow-up commands when roots match but nodes differ", async () => {
    const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-");
    const nodeModules = path.join(servicePrefix, "lib", "node_modules");
    const root = path.join(nodeModules, "openclaw");
    const serviceNode = path.join(servicePrefix, "bin", "node");
    const serviceNpm = path.join(servicePrefix, "bin", "npm");
    const entrypoint = path.join(root, "dist", "index.js");
    await fs.mkdir(path.dirname(entrypoint), { recursive: true });
    await fs.mkdir(path.dirname(serviceNode), { recursive: true });
    await fs.writeFile(serviceNode, "", "utf-8");
    await fs.writeFile(serviceNpm, "", "utf-8");
    const serviceNpmReal = await fs.realpath(serviceNpm);
    await fs.writeFile(
      path.join(root, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
      "utf-8",
    );
    await fs.writeFile(entrypoint, "", "utf-8");
    await writePackageDistInventory(root);
    // Same package root for both shell and service.
    mockPackageInstallStatus(root);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, entrypoint, "gateway"],
    });
    serviceLoaded.mockResolvedValue(true);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
        return {
          stdout: "v24.14.0\n",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        (argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
        argv[1] === "root" &&
        argv[2] === "-g"
      ) {
        return {
          stdout: `${nodeModules}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (
        Array.isArray(argv) &&
        (argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
        argv[1] === "i"
      ) {
        const stagePrefix = argv.includes("--prefix")
          ? argv[argv.indexOf("--prefix") + 1]
          : undefined;
        const stageRoot = stagePrefix
          ? path.join(stagePrefix, "lib", "node_modules", "openclaw")
          : root;
        const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
        await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
        await fs.writeFile(
          path.join(stageRoot, "package.json"),
          JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
          "utf-8",
        );
        await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
        await writePackageDistInventory(stageRoot);
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true });

    // Follow-up commands should use the service Node, not process.execPath.
    expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
    expect(spawnCall()?.[0]).toBe(serviceNode);
    const serviceInstallCall = commandCalls().find(
      ([argv]) => argv[2] === "gateway" && argv[3] === "install",
    );
    expect(serviceInstallCall?.[0][0]).toBe(serviceNode);
  });

  it("pins package install to the service root when nodes differ and no owning npm exists at the prefix", async () => {
    const servicePrefix = await createTrackedTempDir("openclaw-no-npm-prefix-");
    const nodeModules = path.join(servicePrefix, "lib", "node_modules");
    const root = path.join(nodeModules, "openclaw");
    const serviceNode = path.join(servicePrefix, "bin", "node");
    const entrypoint = path.join(root, "dist", "index.js");
    // Create the node binary but intentionally do NOT create <prefix>/bin/npm
    // so resolvePreferredNpmCommand returns null and the PATH npm is used.
    await fs.mkdir(path.dirname(entrypoint), { recursive: true });
    await fs.mkdir(path.dirname(serviceNode), { recursive: true });
    await fs.writeFile(serviceNode, "", "utf-8");
    // No npm binary at servicePrefix/bin/npm!
    await fs.writeFile(
      path.join(root, "package.json"),
      JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
      "utf-8",
    );
    await fs.writeFile(entrypoint, "", "utf-8");
    await writePackageDistInventory(root);
    mockPackageInstallStatus(root);
    serviceReadCommand.mockResolvedValue({
      programArguments: [serviceNode, entrypoint, "gateway"],
    });
    serviceLoaded.mockResolvedValue(true);
    pathExists.mockImplementation(async (candidate: string) => {
      try {
        await fs.access(candidate);
        return true;
      } catch {
        return false;
      }
    });
    // The PATH npm returns a DIFFERENT global root (simulates Node-B's npm).
    const nodeBGlobalRoot = path.join(
      await createTrackedTempDir("node-b-global-"),
      "lib",
      "node_modules",
    );
    await fs.mkdir(nodeBGlobalRoot, { recursive: true });
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
        return {
          stdout: "v24.14.0\n",
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
        // PATH npm returns Node-B's root, NOT the service root.
        return {
          stdout: `${nodeBGlobalRoot}\n`,
          stderr: "",
          code: 0,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i") {
        // Install step: create the expected package structure at the target.
        const prefixIdx = argv.indexOf("--prefix");
        const stagePrefix = prefixIdx >= 0 ? argv[prefixIdx + 1] : undefined;
        const stageRoot = stagePrefix
          ? path.join(stagePrefix, "lib", "node_modules", "openclaw")
          : root;
        const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
        await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
        await fs.writeFile(
          path.join(stageRoot, "package.json"),
          JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
          "utf-8",
        );
        await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
        await writePackageDistInventory(stageRoot);
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ yes: true });

    // The install command must use --prefix pointing to a location within
    // the service root's prefix tree, NOT Node-B's global root.
    const installCall = packageInstallCommandCall();
    expect(installCall).toBeDefined();
    const installArgv = installCall![0];
    const prefixIdx = installArgv.indexOf("--prefix");
    expect(prefixIdx).toBeGreaterThan(-1);
    // Staging prefix should be under the service prefix, not Node-B's.
    expect(installArgv[prefixIdx + 1]).toContain(servicePrefix);
    expect(installArgv[prefixIdx + 1]).not.toContain(nodeBGlobalRoot);
    // Follow-up commands use the service node.
    expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
  });

  it("repairs legacy config before persisting a requested update channel", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    const legacyConfig = {
      channels: {
        slack: {
          streaming: "partial",
          nativeStreaming: false,
        },
        telegram: {
          streaming: "block",
        },
      },
    } as OpenClawConfig;
    const migratedConfig = {
      channels: {
        slack: {
          streaming: {
            mode: "partial",
            nativeTransport: false,
          },
        },
        telegram: {
          streaming: {
            mode: "block",
          },
        },
      },
    } as OpenClawConfig;
    vi.mocked(readConfigFileSnapshot)
      .mockResolvedValueOnce({
        ...baseSnapshot,
        parsed: legacyConfig,
        resolved: legacyConfig,
        sourceConfig: legacyConfig,
        config: legacyConfig,
        runtimeConfig: legacyConfig,
        valid: false,
        hash: "legacy-hash",
        issues: [
          {
            path: "channels.slack.streaming",
            message: "Invalid input: expected object, received string",
          },
        ],
        legacyIssues: [
          {
            path: "channels.slack",
            message: "legacy slack streaming keys",
          },
          {
            path: "channels.telegram",
            message: "legacy telegram streaming keys",
          },
        ],
      })
      .mockResolvedValueOnce({
        ...baseSnapshot,
        parsed: migratedConfig,
        resolved: migratedConfig,
        sourceConfig: migratedConfig,
        config: migratedConfig,
        runtimeConfig: migratedConfig,
        valid: true,
        hash: "migrated-hash",
      })
      .mockResolvedValueOnce({
        ...baseSnapshot,
        parsed: migratedConfig,
        resolved: migratedConfig,
        sourceConfig: migratedConfig,
        config: migratedConfig,
        runtimeConfig: migratedConfig,
        valid: true,
        hash: "migrated-hash",
      })
      .mockResolvedValue({
        ...baseSnapshot,
        parsed: migratedConfig,
        resolved: migratedConfig,
        sourceConfig: migratedConfig,
        config: migratedConfig,
        runtimeConfig: migratedConfig,
        valid: true,
        hash: "migrated-hash",
      });
    legacyConfigRepairMocks.repairLegacyConfigForUpdateChannel.mockImplementationOnce(
      async (params: { configSnapshot: ConfigFileSnapshot; jsonMode: boolean }) => {
        await replaceConfigFile({
          nextConfig: migratedConfig,
          baseHash: params.configSnapshot.hash,
          writeOptions: {
            allowConfigSizeDrop: true,
            skipOutputLogs: params.jsonMode,
          },
        });
        return {
          snapshot: await readConfigFileSnapshot(),
          repaired: true,
        };
      },
    );

    await updateCommand({ channel: "beta", yes: true });

    const repairCall =
      legacyConfigRepairMocks.repairLegacyConfigForUpdateChannel.mock.calls[0]?.[0];
    expect(repairCall?.configSnapshot.hash).toBe("legacy-hash");
    expect(repairCall?.configSnapshot.valid).toBe(false);
    expect(repairCall?.jsonMode).toBe(false);
    expect(replaceConfigFile).toHaveBeenCalledTimes(2);
    const replaceCalls = vi.mocked(replaceConfigFile).mock.calls.map((call) => call[0]);
    expect(replaceCalls[0]).toEqual({
      nextConfig: migratedConfig,
      baseHash: "legacy-hash",
      writeOptions: {
        allowConfigSizeDrop: true,
        skipOutputLogs: false,
      },
    });
    expect(replaceCalls[1]).toEqual({
      nextConfig: {
        ...migratedConfig,
        update: {
          channel: "beta",
        },
      },
      baseHash: "migrated-hash",
    });
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("does not auto-repair legacy config when authored includes are present", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    const legacyConfigWithInclude = {
      $include: "./channels.json5",
      channels: {
        slack: {
          streaming: "partial",
          nativeStreaming: false,
        },
      },
    } as unknown as OpenClawConfig;
    vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
      ...baseSnapshot,
      parsed: legacyConfigWithInclude,
      resolved: legacyConfigWithInclude,
      sourceConfig: legacyConfigWithInclude,
      config: legacyConfigWithInclude,
      runtimeConfig: legacyConfigWithInclude,
      valid: false,
      hash: "legacy-include-hash",
      issues: [
        {
          path: "channels.slack.streaming",
          message: "Invalid input: expected object, received string",
        },
      ],
      legacyIssues: [
        {
          path: "channels.slack",
          message: "legacy slack streaming keys",
        },
      ],
    });

    await updateCommand({ channel: "beta", yes: true });

    expect(replaceConfigFile).not.toHaveBeenCalled();
    expect(runCommandWithTimeout).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
  });

  it("does not repair legacy config during a dry run", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    const legacyConfig = {
      channels: {
        slack: {
          streaming: "partial",
          nativeStreaming: false,
        },
      },
    } as OpenClawConfig;
    vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
      ...baseSnapshot,
      parsed: legacyConfig,
      resolved: legacyConfig,
      sourceConfig: legacyConfig,
      config: legacyConfig,
      runtimeConfig: legacyConfig,
      valid: false,
      hash: "legacy-hash",
      issues: [
        {
          path: "channels.slack.streaming",
          message: "Invalid input: expected object, received string",
        },
      ],
      legacyIssues: [
        {
          path: "channels.slack",
          message: "legacy slack streaming keys",
        },
      ],
    });

    await updateCommand({ dryRun: true, channel: "beta", yes: true });

    expect(replaceConfigFile).not.toHaveBeenCalled();
    expect(runCommandWithTimeout).not.toHaveBeenCalled();
    expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
  });

  it("does not persist the requested channel when the package update fails", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
      if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
        return {
          stdout: "",
          stderr: "install failed",
          code: 1,
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      return {
        stdout: "",
        stderr: "",
        code: 0,
        signal: null,
        killed: false,
        termination: "exit",
      };
    });

    await updateCommand({ channel: "beta", yes: true });

    expect(replaceConfigFile).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
  });

  it("keeps the requested channel when plugin sync writes config after update", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: true,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await updateCommand({ channel: "beta", yes: true });

    const lastWrite = lastReplaceConfigCall() as
      | { nextConfig?: { update?: { channel?: string } } }
      | undefined;
    expect(lastWrite?.nextConfig?.update?.channel).toBe("beta");
  });

  it("refreshes post-doctor config before post-update plugin sync", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    const preUpdateConfig = { update: { channel: "stable" } } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
      meta: { lastTouchedVersion: "2026.5.14" },
    } as OpenClawConfig;
    vi.mocked(readConfigFileSnapshot)
      .mockResolvedValueOnce({
        ...baseSnapshot,
        sourceConfig: preUpdateConfig,
        config: preUpdateConfig,
        hash: "pre-update-hash",
      })
      .mockResolvedValue({
        ...baseSnapshot,
        sourceConfig: postDoctorConfig,
        config: postDoctorConfig,
        hash: "post-doctor-hash",
      });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: true,
      config: {
        ...config,
        plugins: {
          ...config.plugins,
          load: { paths: ["/tmp/openclaw-updated-plugin"] },
        },
      },
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await updateCommand({ yes: true });

    const syncConfig = syncPluginCall()?.config as
      | (OpenClawConfig & { meta?: { lastTouchedVersion?: string } })
      | undefined;
    const lastWrite = lastReplaceConfigCall() as
      | {
          baseHash?: string;
          nextConfig?: OpenClawConfig & { meta?: { lastTouchedVersion?: string } };
        }
      | undefined;
    expect(syncConfig?.meta?.lastTouchedVersion).toBe("2026.5.14");
    expect(lastWrite?.baseHash).toBe("post-doctor-hash");
    expect(lastWrite?.nextConfig?.meta?.lastTouchedVersion).toBe("2026.5.14");
  });

  it("restores pre-update channels when post-core resume sees post-doctor config without them", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const preUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
      meta: { lastTouchedVersion: "2026.5.14" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(`${configPath}.pre-update`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(`${configPath}.bak`, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    const syncConfig = syncPluginCall()?.config as
      | (OpenClawConfig & { meta?: { lastTouchedVersion?: string } })
      | undefined;
    const lastWrite = lastReplaceConfigCall() as
      | {
          baseHash?: string;
          nextConfig?: OpenClawConfig & {
            meta?: { lastTouchedVersion?: string };
            channels?: { whatsapp?: { enabled?: boolean; dmPolicy?: string } };
          };
        }
      | undefined;
    expect(syncConfig?.channels?.whatsapp).toEqual(preUpdateConfig.channels?.whatsapp);
    expect(syncConfig?.meta?.lastTouchedVersion).toBe("2026.5.14");
    expect(lastWrite?.baseHash).toBe("post-doctor-hash");
    expect(lastWrite?.nextConfig?.channels?.whatsapp).toEqual(preUpdateConfig.channels?.whatsapp);
    expect(lastWrite?.nextConfig?.meta?.lastTouchedVersion).toBe("2026.5.14");
  });

  it("restores pre-update channel model overrides when post-core resume restores a channel", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const preUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
        telegram: {
          enabled: true,
        },
        modelByChannel: {
          openai: {
            whatsapp: "openai/gpt-5.5",
            telegram: "openai/gpt-5.4",
          },
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
      channels: {
        telegram: {
          enabled: true,
        },
        modelByChannel: {
          openai: {
            telegram: "openai/gpt-5.4",
          },
        },
      },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(`${configPath}.pre-update`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    const syncConfig = syncPluginCall()?.config as
      | (OpenClawConfig & {
          channels?: {
            modelByChannel?: Record<string, Record<string, string>>;
          };
        })
      | undefined;
    const lastWrite = lastReplaceConfigCall() as
      | {
          nextConfig?: OpenClawConfig & {
            channels?: {
              modelByChannel?: Record<string, Record<string, string>>;
            };
          };
        }
      | undefined;
    expect(syncConfig?.channels?.modelByChannel?.openai?.whatsapp).toBe("openai/gpt-5.5");
    expect(syncConfig?.channels?.modelByChannel?.openai?.telegram).toBe("openai/gpt-5.4");
    expect(lastWrite?.nextConfig?.channels?.modelByChannel?.openai?.whatsapp).toBe(
      "openai/gpt-5.5",
    );
    expect(lastWrite?.nextConfig?.channels?.modelByChannel?.openai?.telegram).toBe(
      "openai/gpt-5.4",
    );
  });

  it("does not restore stale backup channels when current pre-update snapshot has none", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const removedChannelBackup = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    const currentPreUpdateConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(
      `${configPath}.pre-update`,
      `${JSON.stringify(currentPreUpdateConfig)}\n`,
      "utf-8",
    );
    await fs.writeFile(`${configPath}.bak`, `${JSON.stringify(removedChannelBackup)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    expect(syncPluginCall()?.config?.channels?.whatsapp).toBeUndefined();
    expect(lastReplaceConfigCall()).toBeUndefined();
  });

  it("ignores pre-update channel snapshots older than the current update attempt", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const oldPreUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    const updateStartedAtMs = Date.now();
    const staleTime = new Date(updateStartedAtMs - 60_000);
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(
      `${configPath}.pre-update`,
      `${JSON.stringify(oldPreUpdateConfig)}\n`,
      "utf-8",
    );
    await fs.writeFile(`${configPath}.bak`, `${JSON.stringify(oldPreUpdateConfig)}\n`, "utf-8");
    await fs.utimes(`${configPath}.pre-update`, staleTime, staleTime);
    await fs.utimes(`${configPath}.bak`, staleTime, staleTime);
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS: String(updateStartedAtMs),
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    expect(syncPluginCall()?.config?.channels?.whatsapp).toBeUndefined();
    expect(lastReplaceConfigCall()).toBeUndefined();
  });

  it("uses the Windows parent process start time for old post-core parents", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const preUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(`${configPath}.pre-update`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));
    execFile.mockImplementationOnce((...args: unknown[]) => {
      const [file, commandArgs] = args;
      expect(file).toBe("powershell.exe");
      expect(commandArgs).toContain("-NonInteractive");
      const callback = args.at(-1);
      if (typeof callback === "function") {
        callback(null, new Date(Date.now() - 1_000).toISOString(), "");
      }
      return new EventEmitter();
    });
    const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
    Object.defineProperty(process, "platform", {
      configurable: true,
      enumerable: true,
      value: "win32",
    });
    try {
      await withEnvAsync(
        {
          OPENCLAW_UPDATE_POST_CORE: "1",
          OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        },
        async () => {
          await updateCommand({ yes: true, restart: false });
        },
      );
    } finally {
      if (platformDescriptor) {
        Object.defineProperty(process, "platform", platformDescriptor);
      }
    }

    expect(syncPluginCall()?.config?.channels?.whatsapp).toEqual(
      preUpdateConfig.channels?.whatsapp,
    );
    expect(lastReplaceConfigCall()).toBeDefined();
  });

  it("ignores disk fallback snapshots when the update attempt start is unknown", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const preUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          dmPolicy: "pairing",
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(`${configPath}.pre-update`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(`${configPath}.bak`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));
    execFile.mockImplementationOnce((...args: unknown[]) => {
      const callback = args.at(-1);
      if (typeof callback === "function") {
        callback(new Error("ps unavailable"), "", "");
      }
      return new EventEmitter();
    });

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    expect(syncPluginCall()?.config?.channels?.whatsapp).toBeUndefined();
    expect(lastReplaceConfigCall()).toBeUndefined();
  });

  it("persists authored channel values when post-core restore input is resolved", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const sourceConfigPath = path.join(tempDir, "source-config.json");
    const resolvedPreUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          token: "resolved-secret",
        },
      },
    } as OpenClawConfig;
    const authoredPreUpdateConfig = {
      update: { channel: "stable" },
      channels: {
        whatsapp: {
          enabled: true,
          token: "${WHATSAPP_TOKEN}",
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
      meta: { lastTouchedVersion: "2026.5.14" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(
      sourceConfigPath,
      `${JSON.stringify({
        sourceConfig: resolvedPreUpdateConfig,
        authoredConfig: authoredPreUpdateConfig,
      })}\n`,
      "utf-8",
    );
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: sourceConfigPath,
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    const syncConfig = syncPluginCall()?.config as
      | (OpenClawConfig & { channels?: { whatsapp?: { token?: string } } })
      | undefined;
    const lastWrite = lastReplaceConfigCall() as
      | {
          nextConfig?: OpenClawConfig & {
            channels?: { whatsapp?: { token?: string } };
          };
        }
      | undefined;
    expect(syncConfig?.channels?.whatsapp?.token).toBe("resolved-secret");
    expect(lastWrite?.nextConfig?.channels?.whatsapp?.token).toBe("${WHATSAPP_TOKEN}");
  });

  it("resolves included pre-update channels for old post-core parents", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const channelsPath = path.join(tempDir, "channels.json5");
    const includedChannels = {
      whatsapp: {
        enabled: true,
        token: "${WHATSAPP_TOKEN}",
      },
    };
    const preUpdateConfig = {
      update: { channel: "stable" },
      channels: { $include: "./channels.json5" },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
      channels: {},
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(channelsPath, `${JSON.stringify(includedChannels)}\n`, "utf-8");
    await fs.writeFile(`${configPath}.bak`, `${JSON.stringify(preUpdateConfig)}\n`, "utf-8");
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      parsed: postDoctorConfig,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
        WHATSAPP_TOKEN: "resolved-token",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    const syncConfig = syncPluginCall()?.config as
      | (OpenClawConfig & { channels?: { whatsapp?: { token?: string } } })
      | undefined;
    const lastWrite = lastReplaceConfigCall() as
      | {
          nextConfig?: OpenClawConfig & {
            channels?: { $include?: string };
          };
        }
      | undefined;
    expect(syncConfig?.channels?.whatsapp?.token).toBe("resolved-token");
    expect(lastWrite?.nextConfig?.channels).toEqual({ $include: "./channels.json5" });
  });

  it("ignores stale pre-update channel snapshots during post-core resume", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const configPath = path.join(tempDir, "openclaw.json");
    const staleConfig = {
      channels: {
        whatsapp: {
          enabled: true,
        },
      },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "stable" },
    } as OpenClawConfig;
    await fs.mkdir(tempDir, { recursive: true });
    await fs.writeFile(configPath, `${JSON.stringify(postDoctorConfig)}\n`, "utf-8");
    await fs.writeFile(`${configPath}.pre-update`, `${JSON.stringify(staleConfig)}\n`, "utf-8");
    const staleTime = new Date(Date.now() - 7 * 60 * 60 * 1000);
    await fs.utimes(`${configPath}.pre-update`, staleTime, staleTime);
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      path: configPath,
      sourceConfig: postDoctorConfig,
      config: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      hash: "post-doctor-hash",
    });
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await withEnvAsync(
      {
        OPENCLAW_UPDATE_POST_CORE: "1",
        OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
      },
      async () => {
        await updateCommand({ yes: true, restart: false });
      },
    );

    expect(syncPluginCall()?.config?.channels?.whatsapp).toBeUndefined();
    expect(lastReplaceConfigCall()).toBeUndefined();
  });

  it("uses source config and plugin index records for post-update plugin sync", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    const pluginInstallRecords = {
      "lossless-claw": {
        source: "npm",
        spec: "@martian-engineering/lossless-claw",
        installPath: "/tmp/lossless-claw",
      },
    } as const;
    const sourceConfig = {
      plugins: {},
    } as OpenClawConfig;
    loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords);
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      sourceConfig,
      config: {
        ...sourceConfig,
        gateway: { auth: { mode: "token", token: "runtime" } },
        plugins: {
          ...sourceConfig.plugins,
          entries: {
            firecrawl: {
              config: {
                webFetch: { provider: "firecrawl" },
              },
            },
          },
        },
      } as OpenClawConfig,
    });
    syncPluginsForUpdateChannel.mockResolvedValue({
      changed: false,
      config: sourceConfig,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    });
    updateNpmInstalledPlugins.mockResolvedValue({
      changed: false,
      config: sourceConfig,
      outcomes: [],
    });

    await updateCommand({ channel: "beta", yes: true });

    const syncConfig = syncPluginCall()?.config;
    const updateCall = npmPluginUpdateCall() as
      | { skipDisabledPlugins?: boolean; syncOfficialPluginInstalls?: boolean }
      | undefined;
    expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords);
    expect(syncConfig?.update?.channel).toBe("beta");
    expect(syncConfig?.gateway?.auth).toBeUndefined();
    expect(syncConfig?.plugins?.entries).toBeUndefined();
    expect(updateCall?.skipDisabledPlugins).toBe(true);
    expect(updateCall?.syncOfficialPluginInstalls).toBe(true);
  });

  it("persists channel and runs post-update work after switching from package to git", async () => {
    const tempDir = createCaseDir("openclaw-update");
    const gitRoot = path.join(tempDir, "..", "openclaw");
    const completionCacheSpy = vi
      .spyOn(updateCliShared, "tryWriteCompletionCache")
      .mockResolvedValue(undefined);
    mockPackageInstallStatus(tempDir);
    vi.mocked(readConfigFileSnapshot).mockResolvedValue({
      ...baseSnapshot,
      parsed: { update: { channel: "stable" } },
      resolved: { update: { channel: "stable" } } as OpenClawConfig,
      sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
      runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
      config: { update: { channel: "stable" } } as OpenClawConfig,
    });
    vi.mocked(runGatewayUpdate).mockResolvedValue(
      makeOkUpdateResult({
        mode: "git",
        root: gitRoot,
        after: { version: "2026.4.10" },
      }),
    );
    syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      summary: {
        switchedToBundled: [],
        switchedToNpm: [],
        warnings: [],
        errors: [],
      },
    }));
    updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({
      changed: false,
      config,
      outcomes: [],
    }));

    await updateCommand({ channel: "dev", yes: true, restart: false });

    const persistedConfig = replaceConfigCall()?.nextConfig;
    expect(persistedConfig?.update?.channel).toBe("dev");
    const syncCall = syncPluginCall() as
      | { channel?: string; config?: OpenClawConfig; workspaceDir?: string }
      | undefined;
    expect(syncCall?.channel).toBe("dev");
    expect(syncCall?.config?.update?.channel).toBe("dev");
    expect(syncCall?.workspaceDir).toBe(gitRoot);
    expect(npmPluginUpdateCall()?.config?.update?.channel).toBe("dev");
    expect(completionCacheSpy).toHaveBeenCalledWith(gitRoot, false);
    expect(runRestartScript).not.toHaveBeenCalled();
    expect(runDaemonRestart).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });
  it("explains why git updates cannot run with edited files", async () => {
    vi.mocked(defaultRuntime.log).mockClear();
    vi.mocked(defaultRuntime.error).mockClear();
    vi.mocked(defaultRuntime.exit).mockClear();
    vi.mocked(runGatewayUpdate).mockResolvedValue({
      status: "skipped",
      mode: "git",
      reason: "dirty",
      steps: [],
      durationMs: 100,
    } satisfies UpdateRunResult);

    await updateCommand({ channel: "dev" });

    const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
    const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
    expect(errors.join("\n")).toContain("Update blocked: local files are edited in this checkout.");
    expect(logs.join("\n")).toContain(
      "Git-based updates need a clean working tree before they can switch commits, fetch, or rebase.",
    );
    expect(logs.join("\n")).toContain(
      "Commit, stash, or discard the local changes, then rerun `openclaw update`.",
    );
    expect(defaultRuntime.exit).toHaveBeenCalledWith(0);
  });
  it.each([
    {
      name: "refreshes service env when already installed",
      run: async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue({
          status: "ok",
          mode: "git",
          steps: [],
          durationMs: 100,
        } satisfies UpdateRunResult);
        vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
        serviceLoaded.mockResolvedValue(true);

        await updateCommand({});
      },
      assert: () => {
        expect(runDaemonInstall).toHaveBeenCalledWith({
          force: true,
          json: undefined,
        });
        expect(runRestartScript).toHaveBeenCalledTimes(1);
        expect(runDaemonRestart).not.toHaveBeenCalled();
        expect(
          vi
            .mocked(defaultRuntime.log)
            .mock.calls.map((call) => String(call[0]))
            .join("\n"),
        ).toContain("Gateway: restarted and verified.");
      },
    },
    {
      name: "falls back to daemon restart when service env refresh cannot complete",
      run: async () => {
        vi.mocked(runDaemonRestart).mockResolvedValue(true);
        await runRestartFallbackScenario({ daemonInstall: "fail" });
      },
      assert: () => {
        expect(runDaemonInstall).toHaveBeenCalledWith({
          force: true,
          json: undefined,
        });
        expect(runDaemonRestart).toHaveBeenCalledTimes(1);
      },
    },
    {
      name: "keeps going when daemon install succeeds but restart fallback still handles relaunch",
      run: async () => {
        vi.mocked(runDaemonRestart).mockResolvedValue(true);
        await runRestartFallbackScenario({ daemonInstall: "ok" });
      },
      assert: () => {
        expect(runDaemonInstall).toHaveBeenCalledWith({
          force: true,
          json: undefined,
        });
        expect(runDaemonRestart).toHaveBeenCalledTimes(1);
      },
    },
    {
      name: "skips service env refresh when --no-restart is set",
      run: async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
        serviceLoaded.mockResolvedValue(true);

        await updateCommand({ restart: false });
      },
      assert: () => {
        expect(runDaemonInstall).not.toHaveBeenCalled();
        expect(runRestartScript).not.toHaveBeenCalled();
        expect(runDaemonRestart).not.toHaveBeenCalled();
        expect(
          vi
            .mocked(defaultRuntime.log)
            .mock.calls.map((call) => String(call[0]))
            .join("\n"),
        ).toContain("Gateway: restart skipped (--no-restart).");
      },
    },
    {
      name: "skips success message when restart does not run",
      run: async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
        vi.mocked(runDaemonRestart).mockResolvedValue(false);
        vi.mocked(defaultRuntime.log).mockClear();
        await updateCommand({ restart: true });
      },
      assert: () => {
        const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
        expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(
          false,
        );
        expect(logLines.some((line) => line.includes("Gateway: restarted and verified."))).toBe(
          false,
        );
      },
    },
  ] as const)("updateCommand service refresh behavior: $name", runUpdateCliScenario);

  it("fails a package update when service env refresh cannot complete", async () => {
    const tempDir = createCaseDir("openclaw-update");
    mockPackageInstallStatus(tempDir);
    serviceLoaded.mockResolvedValue(true);
    vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed"));

    await updateCommand({ yes: true });

    expect(runDaemonInstall).not.toHaveBeenCalled();
    expect(runRestartScript).not.toHaveBeenCalled();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(
      vi
        .mocked(defaultRuntime.log)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).toContain("updated install entrypoint not found");
  });

  it("fails a JSON package update when fallback restart leaves the old gateway running", async () => {
    const updatedRoot = createCaseDir("openclaw-updated-root");
    const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
    setupUpdatedRootRefresh({
      entrypoints: [updatedEntrypoint],
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: updatedRoot,
          before: { version: "2026.4.23" },
          after: { version: "2026.4.24" },
        }),
    });
    prepareRestartScript.mockResolvedValue(null);
    serviceLoaded.mockResolvedValue(true);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.23",
        connId: "old-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await updateCommand({ yes: true, json: true });

    expect(runRestartScript).not.toHaveBeenCalled();
    expect(runDaemonRestart).not.toHaveBeenCalled();
    const restartCall = gatewayCommandCall(updatedEntrypoint, "restart");
    expect(restartCall?.[0][0]).toContain("node");
    expect(restartCall?.[0].slice(1)).toEqual([updatedEntrypoint, "gateway", "restart", "--json"]);
    expect(restartCall?.[1].cwd).toBe(updatedRoot);
    expect(restartCall?.[1].timeoutMs).toBe(60_000);
    const probeCall = probeGatewayCall() as { includeDetails?: boolean } | undefined;
    expect(probeCall?.includeDetails).toBe(true);
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
    expect(
      vi
        .mocked(defaultRuntime.error)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).toContain(
      "Gateway version mismatch: expected 2026.4.24, running gateway reported 2026.4.23.",
    );
    expect(doctorCommand).not.toHaveBeenCalled();
  });

  it("skips the post-refresh restart script when LaunchAgent already serves the expected package version", async () => {
    const updatedRoot = createCaseDir("openclaw-updated-root");
    const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
    setupUpdatedRootRefresh({
      entrypoints: [updatedEntrypoint],
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: updatedRoot,
          before: { version: "2026.4.23" },
          after: { version: "2026.4.24" },
        }),
    });
    serviceLoaded.mockResolvedValue(true);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.24",
        connId: "updated-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await updateCommand({ yes: true });

    const installCall = gatewayCommandCall(updatedEntrypoint, "install");
    expect(installCall?.[0][0]).toContain("node");
    expect(installCall?.[0].slice(1)).toEqual([updatedEntrypoint, "gateway", "install", "--force"]);
    expect(installCall?.[1].cwd).toBe(updatedRoot);
    expect(installCall?.[1].timeoutMs).toBe(60_000);
    expect(gatewayCommandCall(updatedEntrypoint, "restart")).toBeUndefined();
    expect(runRestartScript).not.toHaveBeenCalled();
    const probeCall = probeGatewayCall() as { includeDetails?: boolean } | undefined;
    expect(probeCall?.includeDetails).toBe(true);
    expect(
      vi
        .mocked(defaultRuntime.log)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).toContain("Gateway: restarted and verified.");
    expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
  });

  it("writes the control-plane update sentinel after managed package restart health passes", async () => {
    const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-");
    const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-");
    const metaPath = path.join(metaDir, "meta.json");
    await fs.writeFile(
      metaPath,
      JSON.stringify({
        version: 1,
        meta: {
          sessionKey: "agent:main:webchat:dm:user-123",
          deliveryContext: { channel: "webchat", to: "webchat:user-123", accountId: "default" },
          note: "Update requested from the agent.",
          continuationMessage: "Check the running version and finish the update report.",
        },
      }),
    );

    const updatedRoot = createCaseDir("openclaw-updated-root");
    const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
    setupUpdatedRootRefresh({
      entrypoints: [updatedEntrypoint],
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: updatedRoot,
          before: { version: "2026.4.23" },
          after: { version: "2026.4.24" },
        }),
    });
    serviceLoaded.mockResolvedValue(true);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.24",
        connId: "updated-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await withEnvAsync(
      {
        [CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
        OPENCLAW_STATE_DIR: stateDir,
      },
      async () => {
        await updateCommand({ yes: true, json: true });
      },
    );

    const raw = await fs.readFile(path.join(stateDir, "restart-sentinel.json"), "utf-8");
    const sentinel = JSON.parse(raw) as {
      payload?: {
        status?: string;
        message?: string | null;
        continuation?: { kind?: string; message?: string };
        stats?: { mode?: string; after?: { version?: string | null } };
      };
    };
    expect(sentinel.payload?.status).toBe("ok");
    expect(sentinel.payload?.message).toBe("Update requested from the agent.");
    expect(sentinel.payload?.continuation).toEqual({
      kind: "agentTurn",
      message: "Check the running version and finish the update report.",
    });
    expect(sentinel.payload?.stats?.mode).toBe("npm");
    expect(sentinel.payload?.stats?.after?.version).toBe("2026.4.24");
  });

  it("marks the control-plane update sentinel failed when restart health verification fails", async () => {
    const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-");
    const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-");
    const metaPath = path.join(metaDir, "meta.json");
    await fs.writeFile(
      metaPath,
      JSON.stringify({
        version: 1,
        meta: {
          sessionKey: "agent:main:webchat:dm:user-123",
          continuationMessage: "This should not report a successful update.",
        },
      }),
    );

    const updatedRoot = createCaseDir("openclaw-updated-root");
    const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
    setupUpdatedRootRefresh({
      entrypoints: [updatedEntrypoint],
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: updatedRoot,
          before: { version: "2026.4.23" },
          after: { version: "2026.4.24" },
        }),
    });
    prepareRestartScript.mockResolvedValue(null);
    serviceLoaded.mockResolvedValue(true);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.23",
        connId: "old-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: null,
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await withEnvAsync(
      {
        [CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
        OPENCLAW_STATE_DIR: stateDir,
      },
      async () => {
        await updateCommand({ yes: true, json: true });
      },
    );

    const raw = await fs.readFile(path.join(stateDir, "restart-sentinel.json"), "utf-8");
    const sentinel = JSON.parse(raw) as {
      payload?: {
        status?: string;
        continuation?: unknown;
        stats?: { reason?: string | null };
      };
    };
    expect(sentinel.payload?.status).toBe("error");
    expect(sentinel.payload?.stats?.reason).toBe("restart-unhealthy");
    expect(sentinel.payload?.continuation).toBeUndefined();
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
  });

  it("fails a package update when the restarted gateway reports activated plugin load errors", async () => {
    const updatedRoot = createCaseDir("openclaw-updated-root");
    const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
    setupUpdatedRootRefresh({
      entrypoints: [updatedEntrypoint],
      gatewayUpdateImpl: async () =>
        makeOkUpdateResult({
          mode: "npm",
          root: updatedRoot,
          before: { version: "2026.4.23" },
          after: { version: "2026.4.24" },
        }),
    });
    readPackageVersion.mockResolvedValue("2026.4.24");
    serviceLoaded.mockResolvedValue(true);
    probeGateway.mockResolvedValue({
      ok: true,
      close: null,
      server: {
        version: "2026.4.24",
        connId: "updated-gateway",
      },
      auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
      health: {
        ok: true,
        plugins: {
          errors: [
            {
              id: "telegram",
              origin: "bundled",
              activated: true,
              error: "failed to load plugin dependency: ENOSPC",
            },
          ],
        },
      },
      status: null,
      presence: null,
      configSnapshot: null,
      connectLatencyMs: 1,
      error: null,
      url: "ws://127.0.0.1:18789",
    });

    await updateCommand({ yes: true });

    expect(runRestartScript).toHaveBeenCalledTimes(1);
    const probeCall = probeGatewayCall() as { includeDetails?: boolean } | undefined;
    expect(probeCall?.includeDetails).toBe(true);
    expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    expect(
      vi
        .mocked(defaultRuntime.log)
        .mock.calls.map((call) => String(call[0]))
        .join("\n"),
    ).toContain("- telegram: failed to load plugin dependency: ENOSPC");
  });

  it.each([
    {
      name: "updateCommand refreshes service env from updated install root when available",
      invoke: async () => {
        await updateCommand({});
      },
      assertExtra: () => {
        expect(runDaemonInstall).not.toHaveBeenCalled();
        expect(runRestartScript).toHaveBeenCalledTimes(1);
      },
    },
    {
      name: "updateCommand preserves invocation-relative service env overrides during refresh",
      invoke: async () => {
        await withEnvAsync(
          {
            OPENCLAW_STATE_DIR: "./state",
            OPENCLAW_CONFIG_PATH: "./config/openclaw.json",
          },
          async () => {
            await updateCommand({});
          },
        );
      },
      expectedEnv: () => ({
        OPENCLAW_STATE_DIR: path.resolve("./state"),
        OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"),
      }),
      assertExtra: () => {
        expect(runDaemonInstall).not.toHaveBeenCalled();
      },
    },
    {
      name: "updateCommand reuses the captured invocation cwd when process.cwd later fails",
      invoke: async () => {
        const originalCwd = process.cwd();
        let restoreCwd: (() => void) | undefined;
        const { root } = setupUpdatedRootRefresh({
          gatewayUpdateImpl: async () => {
            const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
              throw new Error("ENOENT: current working directory is gone");
            });
            restoreCwd = () => cwdSpy.mockRestore();
            return {
              status: "ok",
              mode: "npm",
              root,
              steps: [],
              durationMs: 100,
            };
          },
        });
        try {
          await withEnvAsync(
            {
              OPENCLAW_STATE_DIR: "./state",
            },
            async () => {
              await updateCommand({});
            },
          );
        } finally {
          restoreCwd?.();
        }
        return { originalCwd };
      },
      customSetup: true,
      expectedEnv: (context?: { originalCwd: string }) => ({
        OPENCLAW_STATE_DIR: path.resolve(context?.originalCwd ?? process.cwd(), "./state"),
      }),
      assertExtra: () => {
        expect(runDaemonInstall).not.toHaveBeenCalled();
      },
    },
  ])("$name", async (testCase) => {
    const setup = testCase.customSetup ? undefined : setupUpdatedRootRefresh();
    const context = (await testCase.invoke()) as { originalCwd: string } | undefined;
    const runCommandWithTimeoutMock = vi.mocked(runCommandWithTimeout) as unknown as {
      mock: { calls: Array<[unknown, { cwd?: string }?]> };
    };
    const root = setup?.root ?? runCommandWithTimeoutMock.mock.calls[0]?.[1]?.cwd;
    const entryPath = setup?.entrypoints?.[0] ?? path.join(String(root), "dist", "entry.js");

    const installCall = gatewayCommandCall(entryPath, "install");
    expect(installCall?.[0][0]).toContain("node");
    expect(installCall?.[0].slice(1)).toEqual([entryPath, "gateway", "install", "--force"]);
    expect(installCall?.[1].cwd).toBe(String(root));
    expect(installCall?.[1].timeoutMs).toBe(60_000);
    const expectedEnv =
      "expectedEnv" in testCase && testCase.expectedEnv ? testCase.expectedEnv(context) : {};
    for (const [key, value] of Object.entries(expectedEnv)) {
      expect((installCall?.[1].env as NodeJS.ProcessEnv | undefined)?.[key]).toBe(value);
    }
    testCase.assertExtra();
  });

  it("updateCommand continues after doctor sub-step and clears update flag", async () => {
    const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
    try {
      await withEnvAsync({ OPENCLAW_UPDATE_IN_PROGRESS: undefined }, async () => {
        vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
        vi.mocked(runDaemonRestart).mockResolvedValue(true);
        vi.mocked(doctorCommand).mockResolvedValue(undefined);
        vi.mocked(defaultRuntime.log).mockClear();

        await updateCommand({});

        const doctorCall = vi.mocked(doctorCommand).mock.calls[0];
        expect(doctorCall?.[0]).toBe(defaultRuntime);
        expect(doctorCall?.[1]?.nonInteractive).toBe(true);
        expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();

        const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
        expect(
          logLines.some((line) =>
            line.includes("Leveled up! New skills unlocked. You're welcome."),
          ),
        ).toBe(true);
      });
    } finally {
      randomSpy.mockRestore();
    }
  });

  it("marks the whole update command as update-in-progress", async () => {
    await withEnvAsync({ OPENCLAW_UPDATE_IN_PROGRESS: undefined }, async () => {
      let observedUpdateEnv: string | undefined;
      vi.mocked(runGatewayUpdate).mockImplementationOnce(async () => {
        observedUpdateEnv = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
        return makeOkUpdateResult();
      });

      await updateCommand({ restart: false });

      expect(observedUpdateEnv).toBe("1");
      expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();
    });
  });

  it("updateFinalizeCommand runs doctor and plugin convergence with full update env", async () => {
    await withEnvAsync(
      {
        OPENCLAW_UPDATE_IN_PROGRESS: undefined,
        OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: undefined,
        OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: undefined,
      },
      async () => {
        let doctorEnv: NodeJS.ProcessEnv | undefined;
        vi.mocked(doctorCommand).mockImplementationOnce(async () => {
          doctorEnv = { ...process.env };
        });
        vi.mocked(defaultRuntime.writeJson).mockClear();

        await updateFinalizeCommand({ json: true, yes: true, timeout: "9", restart: false });

        expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
        expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
        expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
        expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();
        expect(process.env.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBeUndefined();
        expect(process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBeUndefined();
        expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, {
          nonInteractive: true,
          repair: true,
          yes: true,
        });
        expect(syncPluginCall()?.channel).toBe("stable");
        expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000);
        expect(
          vi
            .mocked(readConfigFileSnapshot)
            .mock.calls.some(([options]) => options?.skipPluginValidation === true),
        ).toBe(true);
        const output = lastWriteJsonCall() as
          | {
              status?: string;
              mode?: string;
              restart?: boolean;
              postUpdate?: { doctor?: { status?: string }; plugins?: { status?: string } };
            }
          | undefined;
        expect(output?.status).toBe("ok");
        expect(output?.mode).toBe("finalize");
        expect(output?.restart).toBe(false);
        expect(output?.postUpdate?.doctor?.status).toBe("ok");
        expect(output?.postUpdate?.plugins?.status).toBe("ok");
      },
    );
  });

  it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => {
    const preDoctorConfig = {
      update: { channel: "stable" },
      plugins: { entries: { pre: { enabled: true } } },
    } as OpenClawConfig;
    const postDoctorConfig = {
      update: { channel: "beta" },
      plugins: { entries: { post: { enabled: true } } },
    } as OpenClawConfig;
    const preDoctorSnapshot: ConfigFileSnapshot = {
      ...baseSnapshot,
      sourceConfig: preDoctorConfig,
      resolved: preDoctorConfig,
      runtimeConfig: preDoctorConfig,
      config: preDoctorConfig,
      hash: "pre-doctor",
    };
    const postDoctorSnapshot: ConfigFileSnapshot = {
      ...baseSnapshot,
      sourceConfig: postDoctorConfig,
      resolved: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      config: postDoctorConfig,
      hash: "post-doctor",
    };
    const postDoctorRecords = {
      "post-plugin": {
        source: "npm",
        spec: "post-plugin@1.0.0",
      },
    } satisfies Record<string, PluginInstallRecord>;
    vi.mocked(readConfigFileSnapshot)
      .mockResolvedValueOnce(preDoctorSnapshot)
      .mockResolvedValueOnce(postDoctorSnapshot);
    loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords);
    syncPluginsForUpdateChannel.mockImplementationOnce(
      async (params: { config?: OpenClawConfig }) => ({
        changed: true,
        config: params.config ?? baseConfig,
        summary: {
          switchedToBundled: [],
          switchedToNpm: [],
          warnings: [],
          errors: [],
        },
      }),
    );

    await updateFinalizeCommand({ json: true, timeout: "9", restart: false });

    expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, {
      nonInteractive: true,
      repair: true,
      yes: false,
    });
    expect(syncPluginCall()?.channel).toBe("beta");
    expect(syncPluginCall()?.config).toEqual({
      ...postDoctorConfig,
      plugins: {
        ...postDoctorConfig.plugins,
        installs: postDoctorRecords,
      },
    });
    expect(lastReplaceConfigCall()?.baseHash).toBe("post-doctor");
    expect(vi.mocked(doctorCommand).mock.invocationCallOrder[0] ?? 0).toBeLessThan(
      loadInstalledPluginIndexInstallRecords.mock.invocationCallOrder[0] ?? 0,
    );
    expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("beta");
  });

  it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => {
    const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig;
    const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig;
    const preDoctorSnapshot: ConfigFileSnapshot = {
      ...baseSnapshot,
      sourceConfig: preDoctorConfig,
      resolved: preDoctorConfig,
      runtimeConfig: preDoctorConfig,
      config: preDoctorConfig,
      hash: "pre-doctor",
    };
    const postDoctorSnapshot: ConfigFileSnapshot = {
      ...baseSnapshot,
      sourceConfig: postDoctorConfig,
      resolved: postDoctorConfig,
      runtimeConfig: postDoctorConfig,
      config: postDoctorConfig,
      hash: "post-doctor",
    };
    vi.mocked(readConfigFileSnapshot)
      .mockResolvedValueOnce(preDoctorSnapshot)
      .mockResolvedValueOnce(preDoctorSnapshot)
      .mockResolvedValueOnce(postDoctorSnapshot)
      .mockResolvedValueOnce(postDoctorSnapshot);

    await updateFinalizeCommand({ channel: "dev", json: true, restart: false });

    expect(replaceConfigCall(0)?.baseHash).toBe("pre-doctor");
    expect(replaceConfigCall(0)?.nextConfig).toEqual({ update: { channel: "dev" } });
    expect(replaceConfigCall(1)?.baseHash).toBe("post-doctor");
    expect(replaceConfigCall(1)?.nextConfig).toEqual({ update: { channel: "dev" } });
    expect(syncPluginCall()?.channel).toBe("dev");
    expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("dev");
  });

  it.each([
    {
      name: "update command invalid timeout",
      run: async () => await updateCommand({ timeout: "invalid" }),
      requireTty: false,
      expectedError: "--timeout must be a positive integer (seconds)",
    },
    {
      name: "update status command invalid timeout",
      run: async () => await updateStatusCommand({ timeout: "invalid" }),
      requireTty: false,
      expectedError: "--timeout must be a positive integer (seconds)",
    },
    {
      name: "update wizard invalid timeout",
      run: async () => await updateWizardCommand({ timeout: "invalid" }),
      requireTty: true,
      expectedError: "--timeout must be a positive integer (seconds)",
    },
    {
      name: "update wizard requires a TTY",
      run: async () => await updateWizardCommand({}),
      requireTty: false,
      expectedError:
        "Update wizard requires a TTY. Use `openclaw update --channel <stable|beta|dev>` instead.",
    },
  ] as const)(
    "validates update command invocation errors: $name",
    async ({ run, requireTty, expectedError, name }) => {
      setTty(requireTty);
      vi.mocked(defaultRuntime.error).mockClear();
      vi.mocked(defaultRuntime.exit).mockClear();

      await run();

      expect(defaultRuntime.error, name).toHaveBeenCalledWith(expectedError);
      expect(defaultRuntime.exit, name).toHaveBeenCalledWith(1);
    },
  );

  it.each([
    {
      name: "requires confirmation without --yes",
      options: {},
      shouldExit: true,
      shouldRunPackageUpdate: false,
    },
    {
      name: "allows downgrade with --yes",
      options: { yes: true },
      shouldExit: false,
      shouldRunPackageUpdate: true,
    },
  ])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => {
    await setupNonInteractiveDowngrade();
    await updateCommand(options);

    const downgradeMessageSeen = vi
      .mocked(defaultRuntime.error)
      .mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required."));
    expect(downgradeMessageSeen).toBe(shouldExit);
    if (shouldExit) {
      expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
    } else {
      expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
    }
    expect(runGatewayUpdate).not.toHaveBeenCalled();
    expect(
      vi
        .mocked(runCommandWithTimeout)
        .mock.calls.some((call) => Array.isArray(call[0]) && call[0][0] === "npm"),
    ).toBe(shouldRunPackageUpdate);
  });

  it("updateWizardCommand offers dev checkout and forwards selections", async () => {
    const tempDir = createCaseDir("openclaw-update-wizard");
    await withEnvAsync({ OPENCLAW_GIT_DIR: tempDir }, async () => {
      setTty(true);

      vi.mocked(checkUpdateStatus).mockResolvedValue({
        root: "/test/path",
        installKind: "package",
        packageManager: "npm",
        deps: {
          manager: "npm",
          status: "ok",
          lockfilePath: null,
          markerPath: null,
        },
      });
      select.mockResolvedValue("dev");
      confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
      vi.mocked(runGatewayUpdate).mockResolvedValue({
        status: "ok",
        mode: "git",
        steps: [],
        durationMs: 100,
      });

      await updateWizardCommand({});

      const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
      expect(call?.channel).toBe("dev");
    });
  });

  it("uses ~/openclaw as the default dev checkout directory", async () => {
    const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
    try {
      await withEnvAsync(
        {
          HOME: undefined,
          OPENCLAW_GIT_DIR: undefined,
          OPENCLAW_HOME: undefined,
          USERPROFILE: undefined,
        },
        async () => {
          expect(resolveGitInstallDir()).toBe(path.posix.join("/tmp/oc-home", "openclaw"));
        },
      );
    } finally {
      homedirSpy.mockRestore();
    }
  });

  it("uses OPENCLAW_HOME for the default dev checkout directory", async () => {
    const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
    try {
      await withEnvAsync(
        { OPENCLAW_GIT_DIR: undefined, OPENCLAW_HOME: "/srv/openclaw-home" },
        async () => {
          expect(resolveGitInstallDir()).toBe(path.posix.join("/srv/openclaw-home", "openclaw"));
        },
      );
    } finally {
      homedirSpy.mockRestore();
    }
  });

  it("creates the parent directory before cloning the default dev checkout", async () => {
    const root = await createTrackedTempDir("openclaw-update-home-");
    const home = path.join(root, "custom-openclaw-home");
    const checkoutDir = path.join(home, "openclaw");

    await withEnvAsync({ OPENCLAW_GIT_DIR: undefined, OPENCLAW_HOME: home }, async () => {
      const dir = resolveGitInstallDir();
      expect(dir).toBe(checkoutDir);
      await ensureGitCheckout({ dir, timeoutMs: 1_000, env: process.env });
    });

    expect((await fs.stat(home)).isDirectory()).toBe(true);
    const cloneCall = vi
      .mocked(runCommandWithTimeout)
      .mock.calls.find((call) => call[0][0] === "git" && call[0][1] === "clone");
    expect(cloneCall?.[0]).toEqual([
      "git",
      "clone",
      "https://github.com/openclaw/openclaw.git",
      checkoutDir,
    ]);
  });
});
