diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 064f9b7a54e2..02d183d5b4ce 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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 diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index e3706c36c079..44bbc09e9e78 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -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) => 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( { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 71dfae7d5563..21c3d57b97d5 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -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(); diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 052c092f8c4b..10e7d3b7e114 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -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([]); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 7c22d6cf62fc..840884f40351 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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"); } diff --git a/src/daemon/inspect.test.ts b/src/daemon/inspect.test.ts index 74390199de81..93f421000e52 100644 --- a/src/daemon/inspect.test.ts +++ b/src/daemon/inspect.test.ts @@ -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 }); } diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 2e7e507fb14d..eb1b456e0279 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -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 = process.env as Record, + 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];