doctor: clean up legacy Linux gateway services (#21188)

* Doctor: clean up legacy Linux gateway services

* doctor: refactor legacy service cleanup flow

* doctor: fix legacy systemd cleanup map key typing

* doctor: add changelog entry for legacy Linux service cleanup

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Phineas1500
2026-02-22 19:18:59 -05:00
committed by GitHub
parent 1c2c7843a8
commit 8a8faf066e
5 changed files with 190 additions and 25 deletions

View File

@@ -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)

View File

@@ -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.",
);
});
});

View File

@@ -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<string, (typeof removedUnits)[number]> = 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");
}

View File

@@ -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");
});
});

View File

@@ -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 {