// Daemon inspect tests cover service inspection and diagnostic output. 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 { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { detectMarkerLineWithGateway, findExtraGatewayServices, renderGatewayServiceCleanupHints, } from "./inspect.js"; const { execSchtasksMock } = vi.hoisted(() => ({ execSchtasksMock: vi.fn(), })); vi.mock("./schtasks-exec.js", () => ({ execSchtasks: (...args: unknown[]) => execSchtasksMock(...args), })); // File-scope cleanup cannot prevent the nested platform-restoration hooks from running. const tempDirs = useAutoCleanupTempDirTracker(afterEach); // Real content from the openclaw-gateway.service unit file (the canonical gateway unit). const GATEWAY_SERVICE_CONTENTS = `\ [Unit] Description=OpenClaw Gateway After=network-online.target Wants=network-online.target [Service] ExecStart=/usr/bin/node /home/openclaw/.npm-global/lib/node_modules/openclaw/dist/entry.js gateway --port 18789 Restart=always Environment=OPENCLAW_SERVICE_MARKER=openclaw Environment=OPENCLAW_SERVICE_KIND=gateway [Install] WantedBy=default.target `; // Real content from the openclaw-test.service unit file (a non-gateway openclaw service). const TEST_SERVICE_CONTENTS = `\ [Unit] Description=OpenClaw test service After=default.target [Service] Type=simple ExecStart=/bin/sh -c 'while true; do sleep 60; done' Restart=on-failure [Install] WantedBy=default.target `; const CLAWDBOT_GATEWAY_CONTENTS = `\ [Unit] Description=Clawdbot Gateway [Service] ExecStart=/usr/bin/node /opt/clawdbot/dist/entry.js gateway --port 18789 Environment=HOME=/home/clawdbot `; const COMPANION_SERVICE_CONTENTS = `\ [Unit] Description=OpenClaw companion worker After=openclaw-gateway.service Requires=openclaw-gateway.service [Service] ExecStart=/usr/bin/node /opt/openclaw-worker/dist/index.js worker `; const CUSTOM_OPENCLAW_GATEWAY_CONTENTS = `\ [Unit] Description=Custom OpenClaw gateway [Service] ExecStart=/usr/bin/node /opt/openclaw/dist/entry.js gateway --port 18888 `; describe("detectMarkerLineWithGateway", () => { it("returns null for openclaw-test.service (openclaw only in description, no gateway on same line)", () => { expect(detectMarkerLineWithGateway(TEST_SERVICE_CONTENTS)).toBeNull(); }); it("returns openclaw for the canonical gateway unit (ExecStart has both openclaw and gateway)", () => { expect(detectMarkerLineWithGateway(GATEWAY_SERVICE_CONTENTS)).toBe("openclaw"); }); it("returns clawdbot for a clawdbot gateway unit", () => { expect(detectMarkerLineWithGateway(CLAWDBOT_GATEWAY_CONTENTS)).toBe("clawdbot"); }); it.each([ "ExecStart=/usr/bin/openclaw \\\n gateway", "# comment \\\nExecStart=/usr/bin/openclaw gateway", "; comment \\\nExecStart=/usr/bin/openclaw gateway", "ExecStart=/usr/bin/openclaw \\\n# comment\n gateway", ])("detects commands through native comments and continuations: %s", (command) => { expect(detectMarkerLineWithGateway(`[Service]\n${command}\n`)).toBe("openclaw"); }); it.each(["After", "Requires", "Description", "Environment"])( "ignores gateway mentions in %s instead of an executable directive", (key) => { expect(detectMarkerLineWithGateway(`${key}=openclaw gateway\n`)).toBeNull(); }, ); it("ignores dependency-only references to the gateway unit", () => { expect(detectMarkerLineWithGateway(COMPANION_SERVICE_CONTENTS)).toBeNull(); }); it("ignores non-gateway ExecStart commands that only pass gateway-named options", () => { const contents = `[Service]\nExecStart=/usr/bin/openclaw-helper --gateway-url http://127.0.0.1:18789 sync\n`; expect(detectMarkerLineWithGateway(contents)).toBeNull(); }); }); describe("renderGatewayServiceCleanupHints", () => { it("does not suggest removing a gateway when no extra service was detected", () => { expect(renderGatewayServiceCleanupHints([])).toEqual([]); }); it.each([ { title: "targets the detected macOS LaunchAgent instead of the active gateway", platform: "darwin", serviceName: "com.example.openclaw-gateway", source: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist", scope: "user", firstHint: "launchctl bootout gui/$UID/com.example.openclaw-gateway", secondHint: "rm /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist", }, { title: "uses the system domain for a detected macOS LaunchDaemon", platform: "darwin", serviceName: "com.example.openclaw-gateway", source: "plist: /Library/LaunchDaemons/com.example.openclaw-gateway.plist", scope: "system", firstHint: "sudo launchctl bootout system/com.example.openclaw-gateway", secondHint: "sudo rm /Library/LaunchDaemons/com.example.openclaw-gateway.plist", }, { title: "keeps global macOS LaunchAgents in the GUI domain", platform: "darwin", serviceName: "com.example.openclaw-gateway", source: "plist: /Library/LaunchAgents/com.example.openclaw-gateway.plist", scope: "system", firstHint: "launchctl bootout gui/$UID/com.example.openclaw-gateway", secondHint: "sudo rm /Library/LaunchAgents/com.example.openclaw-gateway.plist", }, { title: "inspects the detected user-level systemd unit without removing it", platform: "linux", serviceName: "custom-gateway.service", source: "unit: /home/test/.config/systemd/user/custom-gateway.service", scope: "user", firstHint: "systemctl --user status -- custom-gateway.service", secondHint: "systemctl --user cat -- custom-gateway.service", }, { title: "inspects the detected system-level systemd unit without removing it", platform: "linux", serviceName: "custom-gateway.service", source: "unit: /etc/systemd/system/custom-gateway.service", scope: "system", firstHint: "systemctl --system status -- custom-gateway.service", secondHint: "systemctl --system cat -- custom-gateway.service", }, { title: "terminates systemctl options before a detected unit that begins with a dash", platform: "linux", serviceName: "-custom-gateway.service", source: "unit: /home/test/.config/systemd/user/-custom-gateway.service", scope: "user", firstHint: "systemctl --user status -- -custom-gateway.service", secondHint: "systemctl --user cat -- -custom-gateway.service", }, { title: "shell-quotes detected POSIX service labels and paths", platform: "darwin", serviceName: "com.example.gateway; touch injected", source: "plist: /Users/test/Launch Agents/example's gateway.plist", scope: "user", firstHint: "launchctl bootout gui/$UID/'com.example.gateway; touch injected'", secondHint: "rm '/Users/test/Launch Agents/example'\\''s gateway.plist'", }, ] as const)("$title", ({ platform, serviceName, source, scope, firstHint, secondHint }) => { expect( renderGatewayServiceCleanupHints([ { platform, label: serviceName, detail: source, scope, }, ]), ).toEqual([firstHint, secondHint]); }); 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("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. const isLinux = process.platform === "linux"; it.skipIf(!isLinux)("does not report openclaw-test.service as a gateway service", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile(path.join(systemdDir, "openclaw-test.service"), TEST_SERVICE_CONTENTS); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }); it.skipIf(!isLinux)( "does not report the canonical openclaw-gateway.service as an extra service", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile( path.join(systemdDir, "openclaw-gateway.service"), GATEWAY_SERVICE_CONTENTS, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }, ); it.skipIf(!isLinux)( "reports a legacy clawdbot-gateway service as an extra gateway service", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); const unitPath = path.join(systemdDir, "clawdbot-gateway.service"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile(unitPath, CLAWDBOT_GATEWAY_CONTENTS); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toEqual([ { platform: "linux", label: "clawdbot-gateway.service", detail: `unit: ${unitPath}`, scope: "user", marker: "clawdbot", legacy: true, }, ]); }, ); it.skipIf(!isLinux)("reports an orphaned legacy systemd backup", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); const backupPath = path.join(systemdDir, "clawdbot-gateway.service.bak"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile(backupPath, CLAWDBOT_GATEWAY_CONTENTS); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toEqual([ { platform: "linux", label: "clawdbot-gateway.service", detail: `unit backup: ${backupPath}`, scope: "user", marker: "clawdbot", legacy: true, }, ]); }); it.skipIf(!isLinux)("reports a legacy systemd unit and its backup once", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); const unitPath = path.join(systemdDir, "clawdbot-gateway.service"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile(unitPath, CLAWDBOT_GATEWAY_CONTENTS); await fs.writeFile(`${unitPath}.bak`, CLAWDBOT_GATEWAY_CONTENTS); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toEqual([ { platform: "linux", label: "clawdbot-gateway.service", detail: `unit: ${unitPath}`, scope: "user", marker: "clawdbot", legacy: true, }, ]); }); it.skipIf(!isLinux)( "does not report companion units that only depend on the gateway", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile( path.join(systemdDir, "openclaw-companion.service"), COMPANION_SERVICE_CONTENTS, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }, ); it.skipIf(!isLinux).each(["", "# comment \\\n", "; comment \\\n"])( "reports custom-named gateway units after a physical comment: %j", async (comment) => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); const unitPath = path.join(systemdDir, "custom-openclaw.service"); await fs.mkdir(systemdDir, { recursive: true }); await fs.writeFile( unitPath, CUSTOM_OPENCLAW_GATEWAY_CONTENTS.replace("ExecStart=", `${comment}ExecStart=`), ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toEqual([ { platform: "linux", label: "custom-openclaw.service", detail: `unit: ${unitPath}`, scope: "user", marker: "openclaw", legacy: false, }, ]); }, ); }); describe("findExtraGatewayServices (darwin / scanLaunchdDir) — real filesystem", () => { const originalPlatform = process.platform; beforeEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: "darwin", }); }); afterEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform, }); }); it("does not report LaunchAgent companions that only mention the gateway label", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); await fs.mkdir(launchdDir, { recursive: true }); await fs.writeFile( path.join(launchdDir, "com.example.companion.plist"), ` Labelcom.example.companion KeepAliveOtherJobEnabledai.openclaw.gateway ProgramArguments/usr/local/bin/openclaw-helpersync `, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }); it("does not report LaunchAgent companions that only pass gateway-named options", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); await fs.mkdir(launchdDir, { recursive: true }); await fs.writeFile( path.join(launchdDir, "com.example.companion-options.plist"), ` Labelcom.example.companion-options ProgramArguments/usr/local/bin/openclaw-helper--gateway-urlhttp://127.0.0.1:18789sync `, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }); it("does not report non-gateway LaunchAgents that mention clawdbot in environment values", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); await fs.mkdir(launchdDir, { recursive: true }); await fs.writeFile( path.join(launchdDir, "com.github.facebook.watchman.plist"), ` Labelcom.github.facebook.watchman EnvironmentVariablesPATH/Users/test/Projects/clawdbot2/node_modules/.bin:/opt/homebrew/bin ProgramArguments/opt/homebrew/bin/watchman--foreground `, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toStrictEqual([]); }); it("reports custom LaunchAgents that execute openclaw gateway", async () => { const tmpHome = tempDirs.make("openclaw-test-", os.tmpdir()); const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); const plistPath = path.join(launchdDir, "com.example.openclaw-gateway.plist"); await fs.mkdir(launchdDir, { recursive: true }); await fs.writeFile( plistPath, ` Labelcom.example.openclaw-gateway ProgramArguments/usr/local/bin/openclawgateway--port18888 `, ); const result = await findExtraGatewayServices({ HOME: tmpHome }); expect(result).toEqual([ { platform: "darwin", label: "com.example.openclaw-gateway", detail: `plist: ${plistPath}`, scope: "user", marker: "openclaw", legacy: false, }, ]); expect(renderGatewayServiceCleanupHints(result)).toEqual([ "launchctl bootout gui/$UID/com.example.openclaw-gateway", `rm ${plistPath}`, ]); }); }); describe("findExtraGatewayServices (win32)", () => { const originalPlatform = process.platform; beforeEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: "win32", }); execSchtasksMock.mockReset(); }); afterEach(() => { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform, }); }); it("skips schtasks queries unless deep mode is enabled", async () => { const result = await findExtraGatewayServices({}); expect(result).toStrictEqual([]); expect(execSchtasksMock).not.toHaveBeenCalled(); }); it("returns empty results when schtasks query fails", async () => { execSchtasksMock.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "error", }); const result = await findExtraGatewayServices({}, { deep: true }); expect(result).toStrictEqual([]); }); it("collects only non-openclaw marker tasks from schtasks output", async () => { // Real schtasks /Query /FO LIST /V output prefixes root-folder task // names with a backslash (e.g. TaskName:\OpenClaw Gateway). execSchtasksMock.mockResolvedValueOnce({ code: 0, stdout: [ "TaskName:\\OpenClaw Gateway", "Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run", "", "TaskName: Clawdbot Legacy", "Task To Run: C:\\clawdbot\\clawdbot.exe run", "", "TaskName: Other Task", "Task To Run: C:\\tools\\helper.exe", "", ].join("\n"), stderr: "", }); const result = await findExtraGatewayServices({}, { deep: true }); // The \OpenClaw Gateway task is the live launcher — it must be skipped. // Only the unrelated clawdbot task should be flagged. expect(result).toEqual([ { platform: "win32", label: "Clawdbot Legacy", detail: "task: Clawdbot Legacy, run: C:\\clawdbot\\clawdbot.exe run", scope: "system", marker: "clawdbot", legacy: true, }, ]); }); it("reports duplicate root tasks that only share the gateway task prefix", async () => { execSchtasksMock.mockResolvedValueOnce({ code: 0, stdout: [ "TaskName:\\OpenClaw Gateway", "Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run", "", "TaskName:\\OpenClaw Gateway (dev)", "Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run --profile dev", "", "TaskName:\\OpenClaw Gateway Backup", "Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run", "", ].join("\n"), stderr: "", }); const result = await findExtraGatewayServices({}, { deep: true }); expect(result).toEqual([ { platform: "win32", label: "\\OpenClaw Gateway Backup", detail: "task: \\OpenClaw Gateway Backup, run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run", scope: "system", marker: "openclaw", legacy: false, }, ]); }); });