fix(doctor): keep cleanup hints scoped to detected services (#115559)

This commit is contained in:
Peter Steinberger
2026-07-29 01:08:19 -04:00
committed by GitHub
parent 3bb04a551d
commit aa1e8683d8
7 changed files with 313 additions and 26 deletions

View File

@@ -17,7 +17,7 @@ import type {
import { resolveSecretInputRef } from "../../config/types.secrets.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import { inspectGatewayHeapLimit, type GatewayHeapLimitReport } from "../../daemon/gateway-heap.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import type { ExtraGatewayService, FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
@@ -345,7 +345,7 @@ export type DaemonStatus = {
healthy: boolean;
staleGatewayPids: number[];
};
extraServices: Array<{ label: string; detail: string; scope: string }>;
extraServices: ExtraGatewayService[];
/**
* Plugin version drift report. Surfaces active official external plugins
* whose installed version does not match the running gateway version, which

View File

@@ -12,6 +12,9 @@ const resolveControlUiLinksMock = vi.hoisted(() =>
);
const isSystemdUnavailableDetailMock = vi.hoisted(() => vi.fn(() => false));
const renderSystemdUnavailableHintsMock = vi.hoisted(() => vi.fn<() => string[]>(() => []));
const renderGatewayServiceCleanupHintsMock = vi.hoisted(() =>
vi.fn<(_services: unknown) => string[]>(() => []),
);
const isWSLEnvMock = vi.hoisted(() =>
vi.fn((env?: Record<string, string | undefined>) => Boolean(env?.WSL_DISTRO_NAME)),
);
@@ -35,7 +38,7 @@ vi.mock("../../gateway/control-ui-links.js", () => ({
}));
vi.mock("../../daemon/inspect.js", () => ({
renderGatewayServiceCleanupHints: () => [],
renderGatewayServiceCleanupHints: renderGatewayServiceCleanupHintsMock,
}));
vi.mock("../../daemon/restart-logs.js", () => ({
@@ -93,6 +96,7 @@ describe("printDaemonStatus", () => {
beforeEach(() => {
runtime.log.mockReset();
runtime.error.mockReset();
renderGatewayServiceCleanupHintsMock.mockReset().mockReturnValue([]);
resolveControlUiLinksMock.mockClear();
isSystemdUnavailableDetailMock.mockReset().mockReturnValue(false);
renderSystemdUnavailableHintsMock.mockReset().mockReturnValue([]);
@@ -741,7 +745,14 @@ describe("printDaemonStatus", () => {
listeners: [],
hints: [],
},
extraServices: [{ label: "ai.openclaw.gateway.rescue", scope: "user", detail: "loaded" }],
extraServices: [
{
platform: "darwin",
label: "ai.openclaw.gateway.rescue",
scope: "user",
detail: "loaded",
},
],
},
{ json: false },
);
@@ -751,6 +762,42 @@ describe("printDaemonStatus", () => {
expect(runtime.error).not.toHaveBeenCalled();
});
it("renders cleanup hints for the detected extra gateway without targeting the active gateway", () => {
const extraService = {
platform: "darwin" as const,
label: "com.example.openclaw-gateway",
scope: "user" as const,
detail: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
};
renderGatewayServiceCleanupHintsMock.mockReturnValue([
"launchctl bootout gui/$UID/com.example.openclaw-gateway",
"rm /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
]);
printDaemonStatus(
{
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
runtime: { status: "running", pid: 8000 },
},
extraServices: [extraService],
},
{ json: false },
);
expect(renderGatewayServiceCleanupHintsMock).toHaveBeenCalledWith([extraService]);
expectMockLineContains(
runtime.log,
"Cleanup hint: launchctl bootout gui/$UID/com.example.openclaw-gateway",
);
expect(runtime.log.mock.calls.map(([line]) => line).join("\n")).not.toContain(
"ai.openclaw.gateway",
);
});
it("prints a terse plugin drift warning outside deep mode", () => {
printDaemonStatus(
{

View File

@@ -508,7 +508,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
for (const svc of extraServices) {
defaultRuntime.log(`- ${warnText(svc.label)} (${svc.scope}, ${svc.detail})`);
}
for (const hint of renderGatewayServiceCleanupHints()) {
for (const hint of renderGatewayServiceCleanupHints(extraServices)) {
defaultRuntime.log(`${infoText("Cleanup hint:")} ${hint}`);
}
spacer();

View File

@@ -1716,6 +1716,41 @@ describe("maybeScanExtraGatewayServices", () => {
expectNoteContaining("custom-gateway.service", "Other gateway-like services detected");
});
it("renders cleanup hints only for the detected extra macOS gateway", async () => {
mockProcessPlatform("darwin");
const extraService = {
platform: "darwin" as const,
label: "com.example.openclaw-gateway",
detail: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
scope: "user" as const,
legacy: false,
};
mocks.findExtraGatewayServices.mockResolvedValue([extraService]);
mocks.renderGatewayServiceCleanupHints.mockReturnValue([
"launchctl bootout gui/$UID/com.example.openclaw-gateway",
"rm /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
]);
await maybeScanExtraGatewayServices({ deep: false }, makeDoctorIo(), makeDoctorPrompts());
expect(mocks.renderGatewayServiceCleanupHints).toHaveBeenCalledWith([extraService]);
expectNoteContaining("com.example.openclaw-gateway", "Cleanup hints");
expectNoNoteContaining("ai.openclaw.gateway", "Cleanup hints");
});
it("does not render generic cleanup hints for legacy gateway services", async () => {
setupLegacyMacService();
mocks.renderGatewayServiceCleanupHints.mockReturnValue([]);
await maybeScanExtraGatewayServices({ deep: false }, makeDoctorIo(), {
...makeDoctorPrompts(),
confirmRuntimeRepair: vi.fn().mockResolvedValue(false),
});
expect(mocks.renderGatewayServiceCleanupHints).toHaveBeenCalledWith([]);
expectNoNoteContaining("ai.openclaw.gateway", "Cleanup hints");
});
it("threads deep scans through structured extra gateway service detection", async () => {
mocks.findExtraGatewayServices.mockResolvedValue([]);

View File

@@ -990,7 +990,11 @@ export async function maybeScanExtraGatewayServices(
}
}
const cleanupHints = renderGatewayServiceCleanupHints();
// Legacy jobs have their own confirmed cleanup flow; generic hints must
// only name detected extra services, never the active managed gateway.
const cleanupHints = renderGatewayServiceCleanupHints(
extraServices.filter((service) => service.legacy !== true),
);
if (cleanupHints.length > 0) {
note(cleanupHints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints");
}

View File

@@ -3,7 +3,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { detectMarkerLineWithGateway, findExtraGatewayServices } from "./inspect.js";
import {
detectMarkerLineWithGateway,
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
} from "./inspect.js";
const { execSchtasksMock } = vi.hoisted(() => ({
execSchtasksMock: vi.fn(),
@@ -100,6 +104,166 @@ describe("detectMarkerLineWithGateway", () => {
});
});
describe("renderGatewayServiceCleanupHints", () => {
it("does not suggest removing a gateway when no extra service was detected", () => {
expect(renderGatewayServiceCleanupHints([])).toEqual([]);
});
it("targets the detected macOS LaunchAgent instead of the active gateway", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "darwin",
label: "com.example.openclaw-gateway",
detail: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
scope: "user",
},
]),
).toEqual([
"launchctl bootout gui/$UID/com.example.openclaw-gateway",
"rm /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist",
]);
});
it("uses the system domain for a detected macOS LaunchDaemon", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "darwin",
label: "com.example.openclaw-gateway",
detail: "plist: /Library/LaunchDaemons/com.example.openclaw-gateway.plist",
scope: "system",
},
]),
).toEqual([
"sudo launchctl bootout system/com.example.openclaw-gateway",
"sudo rm /Library/LaunchDaemons/com.example.openclaw-gateway.plist",
]);
});
it("keeps global macOS LaunchAgents in the GUI domain", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "darwin",
label: "com.example.openclaw-gateway",
detail: "plist: /Library/LaunchAgents/com.example.openclaw-gateway.plist",
scope: "system",
},
]),
).toEqual([
"launchctl bootout gui/$UID/com.example.openclaw-gateway",
"sudo rm /Library/LaunchAgents/com.example.openclaw-gateway.plist",
]);
});
it("targets the detected user-level systemd unit", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "linux",
label: "custom-gateway.service",
detail: "unit: /home/test/.config/systemd/user/custom-gateway.service",
scope: "user",
},
]),
).toEqual([
"systemctl --user disable --now -- custom-gateway.service",
"rm /home/test/.config/systemd/user/custom-gateway.service",
]);
});
it("targets the detected system-level systemd unit", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "linux",
label: "custom-gateway.service",
detail: "unit: /etc/systemd/system/custom-gateway.service",
scope: "system",
},
]),
).toEqual([
"sudo systemctl disable --now -- custom-gateway.service",
"sudo rm /etc/systemd/system/custom-gateway.service",
]);
});
it("targets the detected Windows scheduled task", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "win32",
label: "\\OpenClaw Gateway Backup",
detail: "task: \\OpenClaw Gateway Backup",
scope: "system",
},
]),
).toEqual(['schtasks /Delete /TN "\\OpenClaw Gateway Backup" /F']);
});
it.each(["$(Start-Process calc)", "%OPENCLAW_GATEWAY_TASK%", "unsafe&task", "task`name"])(
"does not render a Windows task name expandable by cmd.exe or PowerShell: %s",
(label) => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "win32",
label,
detail: `task: ${label}`,
scope: "system",
},
]),
).toEqual([]);
},
);
it("terminates systemctl options before a detected unit that begins with a dash", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "linux",
label: "-custom-gateway.service",
detail: "unit: /home/test/.config/systemd/user/-custom-gateway.service",
scope: "user",
},
]),
).toEqual([
"systemctl --user disable --now -- -custom-gateway.service",
"rm /home/test/.config/systemd/user/-custom-gateway.service",
]);
});
it("shell-quotes detected POSIX service labels and paths", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "darwin",
label: "com.example.gateway; touch injected",
detail: "plist: /Users/test/Launch Agents/example's gateway.plist",
scope: "user",
},
]),
).toEqual([
"launchctl bootout gui/$UID/'com.example.gateway; touch injected'",
"rm '/Users/test/Launch Agents/example'\\''s gateway.plist'",
]);
});
it("does not invent a removal path when service metadata omits it", () => {
expect(
renderGatewayServiceCleanupHints([
{
platform: "darwin",
label: "com.example.openclaw-gateway",
detail: "loaded",
scope: "user",
},
]),
).toEqual(["launchctl bootout gui/$UID/com.example.openclaw-gateway"]);
});
});
describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem", () => {
// These tests write real .service files to a temp dir and call findExtraGatewayServices
// with that dir as HOME. No platform mocking or fs mocking needed.
@@ -314,6 +478,10 @@ describe("findExtraGatewayServices (darwin / scanLaunchdDir) — real filesystem
legacy: false,
},
]);
expect(renderGatewayServiceCleanupHints(result)).toEqual([
"launchctl bootout gui/$UID/com.example.openclaw-gateway",
`rm ${plistPath}`,
]);
} finally {
await fs.rm(tmpHome, { recursive: true, force: true });
}

View File

@@ -41,29 +41,62 @@ const SYSTEMD_REFERENCE_ONLY_KEYS = new Set([
"wants",
]);
function quotePosixCleanupArgument(value: string): string {
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
}
export function renderGatewayServiceCleanupHints(
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
services: readonly ExtraGatewayService[] = [],
): string[] {
const profile = env.OPENCLAW_PROFILE;
switch (process.platform) {
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [`launchctl bootout gui/$UID/${label}`, `rm ~/Library/LaunchAgents/${label}.plist`];
const hints: string[] = [];
for (const service of services) {
switch (service.platform) {
case "darwin": {
const plistPath = service.detail.startsWith("plist:")
? service.detail.slice("plist:".length).trim()
: undefined;
// Global LaunchAgents still run in a GUI domain; only LaunchDaemons
// belong to the system domain regardless of their shared file scope.
const domain =
service.scope === "system" && plistPath?.startsWith("/Library/LaunchDaemons/")
? "system"
: "gui/$UID";
const launchctlCommand = domain === "system" ? "sudo launchctl" : "launchctl";
hints.push(
`${launchctlCommand} bootout ${domain}/${quotePosixCleanupArgument(service.label)}`,
);
if (plistPath) {
const removeCommand = service.scope === "system" ? "sudo rm" : "rm";
hints.push(`${removeCommand} ${quotePosixCleanupArgument(plistPath)}`);
}
break;
}
case "linux": {
const systemctlCommand = service.scope === "user" ? "systemctl --user" : "sudo systemctl";
hints.push(
`${systemctlCommand} disable --now -- ${quotePosixCleanupArgument(service.label)}`,
);
if (service.detail.startsWith("unit:")) {
const unitPath = service.detail.slice("unit:".length).trim();
if (unitPath) {
const removeCommand = service.scope === "system" ? "sudo rm" : "rm";
hints.push(`${removeCommand} ${quotePosixCleanupArgument(unitPath)}`);
}
}
break;
}
case "win32":
// The hint can be pasted into cmd.exe or PowerShell, so exclude names
// that either shell can expand rather than guessing a common escape.
if (/^[A-Za-z0-9_. ()\\/-]+$/.test(service.label)) {
hints.push(`schtasks /Delete /TN "${service.label}" /F`);
}
break;
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [
`systemctl --user disable --now ${unit}.service`,
`rm ~/.config/systemd/user/${unit}.service`,
];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [`schtasks /Delete /TN "${task}" /F`];
}
default:
return [];
}
return hints;
}
type Marker = (typeof EXTRA_MARKERS)[number];