mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 13:31:38 +00:00
fix(doctor): keep cleanup hints scoped to detected services (#115559)
This commit is contained in:
committed by
GitHub
parent
3bb04a551d
commit
aa1e8683d8
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user