diff --git a/CHANGELOG.md b/CHANGELOG.md index 87151d47c8c..12efca71e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -627,6 +627,7 @@ Docs: https://docs.openclaw.ai - CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) - CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) - CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. +- CLI/Doctor: clean up legacy Linux gateway services (`clawdbot`/`moltbot`) during `doctor --fix`, while keeping non-legacy user/system services untouched. (#21063) Thanks @Phineas1500. - Gateway/Update: prevent restart crash loops after failed self-updates by restarting only on successful updates, stopping early on failed install/build steps, and running `openclaw doctor --fix` during updates to sanitize config. (#18131) Thanks @RamiNoodle733. - Gateway/Update: preserve update.run restart delivery context so post-update status replies route back to the initiating channel/thread. (#18267) Thanks @yinghaosang. - CLI/Update: run a standalone restart helper after updates, honoring service-name overrides and reporting restart initiation separately from confirmed restarts. (#18050) diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index a09550fe047..359a304f856 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -9,6 +9,9 @@ const mocks = vi.hoisted(() => ({ buildGatewayInstallPlan: vi.fn(), resolveGatewayPort: vi.fn(() => 18789), resolveIsNixMode: vi.fn(() => false), + findExtraGatewayServices: vi.fn().mockResolvedValue([]), + renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), + uninstallLegacySystemdUnits: vi.fn().mockResolvedValue([]), note: vi.fn(), })); @@ -18,8 +21,8 @@ vi.mock("../config/paths.js", () => ({ })); vi.mock("../daemon/inspect.js", () => ({ - findExtraGatewayServices: vi.fn().mockResolvedValue([]), - renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), + findExtraGatewayServices: mocks.findExtraGatewayServices, + renderGatewayServiceCleanupHints: mocks.renderGatewayServiceCleanupHints, })); vi.mock("../daemon/runtime-paths.js", () => ({ @@ -42,6 +45,10 @@ vi.mock("../daemon/service.js", () => ({ }), })); +vi.mock("../daemon/systemd.js", () => ({ + uninstallLegacySystemdUnits: mocks.uninstallLegacySystemdUnits, +})); + vi.mock("../terminal/note.js", () => ({ note: mocks.note, })); @@ -50,7 +57,10 @@ vi.mock("./daemon-install-helpers.js", () => ({ buildGatewayInstallPlan: mocks.buildGatewayInstallPlan, })); -import { maybeRepairGatewayServiceConfig } from "./doctor-gateway-services.js"; +import { + maybeRepairGatewayServiceConfig, + maybeScanExtraGatewayServices, +} from "./doctor-gateway-services.js"; function makeDoctorIo() { return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; @@ -163,3 +173,58 @@ describe("maybeRepairGatewayServiceConfig", () => { }); }); }); + +describe("maybeScanExtraGatewayServices", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findExtraGatewayServices.mockResolvedValue([]); + mocks.renderGatewayServiceCleanupHints.mockReturnValue([]); + mocks.uninstallLegacySystemdUnits.mockResolvedValue([]); + }); + + it("removes legacy Linux user systemd services", async () => { + mocks.findExtraGatewayServices.mockResolvedValue([ + { + platform: "linux", + label: "moltbot-gateway.service", + detail: "unit: /home/test/.config/systemd/user/moltbot-gateway.service", + scope: "user", + legacy: true, + }, + ]); + mocks.uninstallLegacySystemdUnits.mockResolvedValue([ + { + name: "moltbot-gateway", + unitPath: "/home/test/.config/systemd/user/moltbot-gateway.service", + enabled: true, + exists: true, + }, + ]); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const prompter = { + confirm: vi.fn(), + confirmRepair: vi.fn(), + confirmAggressive: vi.fn(), + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(true), + select: vi.fn(), + shouldRepair: false, + shouldForce: false, + }; + + await maybeScanExtraGatewayServices({ deep: false }, runtime, prompter); + + expect(mocks.uninstallLegacySystemdUnits).toHaveBeenCalledTimes(1); + expect(mocks.uninstallLegacySystemdUnits).toHaveBeenCalledWith({ + env: process.env, + stdout: process.stdout, + }); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("moltbot-gateway.service"), + "Legacy gateway removed", + ); + expect(runtime.log).toHaveBeenCalledWith( + "Legacy gateway services removed. Installing OpenClaw gateway next.", + ); + }); +}); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 445087dc1b6..04a0b1eeda5 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { promisify } from "node:util"; import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; -import { findExtraGatewayServices, renderGatewayServiceCleanupHints } from "../daemon/inspect.js"; +import { + findExtraGatewayServices, + renderGatewayServiceCleanupHints, + type ExtraGatewayService, +} from "../daemon/inspect.js"; import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js"; import { auditGatewayServiceConfig, @@ -13,6 +17,7 @@ import { SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { uninstallLegacySystemdUnits } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { buildGatewayInstallPlan } from "./daemon-install-helpers.js"; @@ -98,6 +103,95 @@ async function cleanupLegacyLaunchdService(params: { } } +function classifyLegacyServices(legacyServices: ExtraGatewayService[]): { + darwinUserServices: ExtraGatewayService[]; + linuxUserServices: ExtraGatewayService[]; + failed: string[]; +} { + const darwinUserServices: ExtraGatewayService[] = []; + const linuxUserServices: ExtraGatewayService[] = []; + const failed: string[] = []; + + for (const svc of legacyServices) { + if (svc.platform === "darwin") { + if (svc.scope === "user") { + darwinUserServices.push(svc); + } else { + failed.push(`${svc.label} (${svc.scope})`); + } + continue; + } + + if (svc.platform === "linux") { + if (svc.scope === "user") { + linuxUserServices.push(svc); + } else { + failed.push(`${svc.label} (${svc.scope})`); + } + continue; + } + + failed.push(`${svc.label} (${svc.platform})`); + } + + return { darwinUserServices, linuxUserServices, failed }; +} + +async function cleanupLegacyDarwinServices( + services: ExtraGatewayService[], +): Promise<{ removed: string[]; failed: string[] }> { + const removed: string[] = []; + const failed: string[] = []; + + for (const svc of services) { + const plistPath = extractDetailPath(svc.detail, "plist:"); + if (!plistPath) { + failed.push(`${svc.label} (missing plist path)`); + continue; + } + const dest = await cleanupLegacyLaunchdService({ + label: svc.label, + plistPath, + }); + removed.push(dest ? `${svc.label} -> ${dest}` : svc.label); + } + + return { removed, failed }; +} + +async function cleanupLegacyLinuxUserServices( + services: ExtraGatewayService[], + runtime: RuntimeEnv, +): Promise<{ removed: string[]; failed: string[] }> { + const removed: string[] = []; + const failed: string[] = []; + + try { + const removedUnits = await uninstallLegacySystemdUnits({ + env: process.env, + stdout: process.stdout, + }); + const removedByLabel: Map = new Map( + removedUnits.map((unit) => [`${unit.name}.service`, unit] as const), + ); + for (const svc of services) { + const removedUnit = removedByLabel.get(svc.label); + if (!removedUnit) { + failed.push(`${svc.label} (legacy unit name not recognized)`); + continue; + } + removed.push(`${svc.label} -> ${removedUnit.unitPath}`); + } + } catch (err) { + runtime.error(`Legacy Linux gateway cleanup failed: ${String(err)}`); + for (const svc of services) { + failed.push(`${svc.label} (linux cleanup failed)`); + } + } + + return { removed, failed }; +} + export async function maybeRepairGatewayServiceConfig( cfg: OpenClawConfig, mode: "local" | "remote", @@ -246,27 +340,21 @@ export async function maybeScanExtraGatewayServices( }); if (shouldRemove) { const removed: string[] = []; - const failed: string[] = []; - for (const svc of legacyServices) { - if (svc.platform !== "darwin") { - failed.push(`${svc.label} (${svc.platform})`); - continue; - } - if (svc.scope !== "user") { - failed.push(`${svc.label} (${svc.scope})`); - continue; - } - const plistPath = extractDetailPath(svc.detail, "plist:"); - if (!plistPath) { - failed.push(`${svc.label} (missing plist path)`); - continue; - } - const dest = await cleanupLegacyLaunchdService({ - label: svc.label, - plistPath, - }); - removed.push(dest ? `${svc.label} -> ${dest}` : svc.label); + const { darwinUserServices, linuxUserServices, failed } = + classifyLegacyServices(legacyServices); + + if (darwinUserServices.length > 0) { + const result = await cleanupLegacyDarwinServices(darwinUserServices); + removed.push(...result.removed); + failed.push(...result.failed); } + + if (linuxUserServices.length > 0) { + const result = await cleanupLegacyLinuxUserServices(linuxUserServices, runtime); + removed.push(...result.removed); + failed.push(...result.failed); + } + if (removed.length > 0) { note(removed.map((line) => `- ${line}`).join("\n"), "Legacy gateway removed"); } diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index 469832e41b0..f8329c519e8 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -4,6 +4,7 @@ import { GATEWAY_LAUNCH_AGENT_LABEL, GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, + LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, normalizeGatewayProfile, resolveGatewayLaunchAgentLabel, resolveGatewayProfileSuffix, @@ -128,3 +129,10 @@ describe("resolveGatewayServiceDescription", () => { ).toBe("OpenClaw Gateway (profile: work, vremote)"); }); }); + +describe("LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES", () => { + it("includes known pre-rebrand gateway unit names", () => { + expect(LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES).toContain("clawdbot-gateway"); + expect(LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES).toContain("moltbot-gateway"); + }); +}); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 3ee523b1535..2f447cf1214 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -11,7 +11,10 @@ export const NODE_SERVICE_MARKER = "openclaw"; export const NODE_SERVICE_KIND = "node"; export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd"; export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS: string[] = []; -export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = []; +export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [ + "clawdbot-gateway", + "moltbot-gateway", +]; export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = []; export function normalizeGatewayProfile(profile?: string): string | null {