From dd0f5937d2b54cd7069ccf3a992ba3906bb53107 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 23:30:23 +0100 Subject: [PATCH] fix(doctor): avoid companion gateway service false positives --- CHANGELOG.md | 1 + docs/cli/doctor.md | 1 + docs/gateway/doctor.md | 1 + src/commands/doctor-gateway-services.test.ts | 173 ++++++++++++++++++ src/commands/doctor-gateway-services.ts | 93 +++++++++- .../doctor-service-audit.test-helpers.ts | 1 + src/daemon/inspect.test.ts | 161 ++++++++++++++++ src/daemon/inspect.ts | 121 ++++++++++-- src/daemon/systemd.test.ts | 30 +++ src/daemon/systemd.ts | 16 ++ 10 files changed, 578 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3164382c354..0cbea5228c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber. - CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020. +- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk. - Daemon/service: only emit hard-coded version-manager paths such as `~/.volta/bin`, `~/.asdf/shims`, `~/.bun/bin`, and fnm/pnpm fallbacks into gateway and node service PATHs when the directories exist, so `openclaw doctor` no longer flags `gateway.path.non-minimal` against a PATH the daemon just wrote. Env-driven roots and stable user-bin dirs remain unconditional. Fixes #71944; carries forward #71964. Thanks @Sanjays2402. - CLI/startup: disable Node's module compile cache automatically for live source-checkout launchers so in-place `pnpm build` updates are visible to the next `openclaw` CLI invocation. Fixes #73037. Thanks @LouisGameDev. - Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 7b7c29ecbb0..cb33e59e1a0 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -47,6 +47,7 @@ Notes: - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. +- On Linux, doctor ignores inactive extra gateway-like systemd units and does not rewrite command/entrypoint metadata for a running systemd gateway service during repair. Stop the service first or use `openclaw gateway install --force` when you intentionally want to replace the active launcher. - Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.`. - Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index d39459606a7..a1d6d037d76 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -440,6 +440,7 @@ That stages grounded durable candidates into the short-term dreaming store while - `openclaw doctor --repair` applies recommended fixes without prompts. - `openclaw doctor --repair --force` overwrites custom supervisor configs. - `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle. + - On Linux, doctor does not rewrite command/entrypoint metadata while the matching systemd gateway unit is active. It also ignores inactive non-legacy extra gateway-like units during the duplicate-service scan so companion service files do not create cleanup noise. - If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata. - Doctor detects managed `.env`/SecretRef-backed service environment values that older LaunchAgent, systemd, or Windows Scheduled Task installs embedded inline and rewrites the service metadata so those values load from the runtime source instead of the supervisor definition. - Doctor detects when the service command still pins an old `--port` after `gateway.port` changes and rewrites the service metadata to the current port. diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 3af58045e61..73f83392f7b 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => ({ resolveIsNixMode: vi.fn(() => false), findExtraGatewayServices: vi.fn().mockResolvedValue([]), renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), + isSystemdUnitActive: vi.fn().mockResolvedValue(false), uninstallLegacySystemdUnits: vi.fn().mockResolvedValue([]), note: vi.fn(), })); @@ -67,6 +68,7 @@ vi.mock("../daemon/service-audit.js", () => ({ needsNodeRuntimeMigration: vi.fn(() => false), readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest, SERVICE_AUDIT_CODES: { + gatewayCommandMissing: testServiceAuditCodes.gatewayCommandMissing, gatewayEntrypointMismatch: testServiceAuditCodes.gatewayEntrypointMismatch, gatewayManagedEnvEmbedded: testServiceAuditCodes.gatewayManagedEnvEmbedded, gatewayPortMismatch: testServiceAuditCodes.gatewayPortMismatch, @@ -84,6 +86,7 @@ vi.mock("../daemon/service.js", () => ({ })); vi.mock("../daemon/systemd.js", () => ({ + isSystemdUnitActive: mocks.isSystemdUnitActive, uninstallLegacySystemdUnits: mocks.uninstallLegacySystemdUnits, })); @@ -106,6 +109,7 @@ import { import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; const originalStdinIsTTY = process.stdin.isTTY; +const originalPlatform = process.platform; const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; function makeDoctorIo() { @@ -131,6 +135,13 @@ function makeDoctorPrompts() { }; } +function mockProcessPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + async function runRepair(cfg: OpenClawConfig) { await maybeRepairGatewayServiceConfig(cfg, "local", makeDoctorIo(), makeDoctorPrompts()); } @@ -232,6 +243,7 @@ describe("maybeRepairGatewayServiceConfig", () => { vi.clearAllMocks(); fsMocks.realpath.mockImplementation(async (value: string) => value); mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.isSystemdUnitActive.mockResolvedValue(false); mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => { const configToken = typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : undefined; @@ -245,6 +257,7 @@ describe("maybeRepairGatewayServiceConfig", () => { value: originalStdinIsTTY, configurable: true, }); + mockProcessPlatform(originalPlatform); if (originalUpdateInProgress === undefined) { delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; } else { @@ -546,6 +559,108 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).toHaveBeenCalledTimes(1); }); + it("skips entrypoint rewrites for an active systemd unit", async () => { + mockProcessPlatform("linux"); + mocks.readCommand.mockResolvedValue({ + ...createGatewayCommand("/opt/old-openclaw/dist/index.js"), + sourcePath: "/etc/systemd/system/custom-gateway.service", + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + ...createGatewayCommand("/opt/new-openclaw/dist/index.js"), + workingDirectory: "/tmp", + }); + mocks.isSystemdUnitActive.mockResolvedValue(true); + + await runRepair({ gateway: {} }); + + expect(mocks.isSystemdUnitActive).toHaveBeenCalledWith( + process.env, + "custom-gateway.service", + "system", + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("skipped command/entrypoint rewrites"), + "Gateway service config", + ); + expect(mocks.install).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + }); + + it("repairs entrypoint drift when the systemd unit is stopped", async () => { + mockProcessPlatform("linux"); + mocks.readCommand.mockResolvedValue({ + ...createGatewayCommand("/opt/old-openclaw/dist/index.js"), + sourcePath: "/home/test/.config/systemd/user/custom-gateway.service", + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + ...createGatewayCommand("/opt/new-openclaw/dist/index.js"), + workingDirectory: "/tmp", + }); + mocks.isSystemdUnitActive.mockResolvedValue(false); + + await runRepair({ gateway: {} }); + + expect(mocks.isSystemdUnitActive).toHaveBeenCalledWith( + process.env, + "custom-gateway.service", + "user", + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + expect(mocks.stage).not.toHaveBeenCalled(); + }); + + it("leaves all service metadata unchanged when an active unit has command drift plus other issues", async () => { + mockProcessPlatform("linux"); + mocks.readCommand.mockResolvedValue({ + programArguments: ["/usr/bin/openclaw", "run"], + environment: {}, + sourcePath: "/home/test/.config/systemd/user/openclaw-gateway.service", + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [ + { + code: "gateway-command-missing", + message: "Service command does not include the gateway subcommand", + level: "aggressive", + }, + { + code: "gateway-port-mismatch", + message: "Gateway service port does not match current gateway config.", + detail: "18789 -> 18888", + level: "recommended", + }, + ], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: gatewayProgramArguments, + workingDirectory: "/tmp", + environment: {}, + }); + mocks.isSystemdUnitActive.mockResolvedValue(true); + + await runRepair({ gateway: { port: 18888 } }); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway service port does not match current gateway config."), + "Gateway service config", + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("leaving supervisor metadata unchanged"), + "Gateway service config", + ); + expect(mocks.install).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + }); + it("repairs entrypoint mismatch in non-interactive fix mode", async () => { setupGatewayEntrypointRepairScenario({ currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js", @@ -793,10 +908,68 @@ describe("maybeScanExtraGatewayServices", () => { vi.clearAllMocks(); mocks.findExtraGatewayServices.mockResolvedValue([]); mocks.renderGatewayServiceCleanupHints.mockReturnValue([]); + mocks.isSystemdUnitActive.mockResolvedValue(false); mocks.uninstallLegacySystemdUnits.mockResolvedValue([]); }); + afterEach(() => { + mockProcessPlatform(originalPlatform); + }); + + it("ignores inactive non-legacy Linux gateway-like services", async () => { + mockProcessPlatform("linux"); + mocks.findExtraGatewayServices.mockResolvedValue([ + { + platform: "linux", + label: "custom-gateway.service", + detail: "unit: /home/test/.config/systemd/user/custom-gateway.service", + scope: "user", + legacy: false, + }, + ]); + mocks.isSystemdUnitActive.mockResolvedValue(false); + + await maybeScanExtraGatewayServices({ deep: false }, makeDoctorIo(), makeDoctorPrompts()); + + expect(mocks.isSystemdUnitActive).toHaveBeenCalledWith( + process.env, + "custom-gateway.service", + "user", + ); + expect(mocks.note).not.toHaveBeenCalledWith( + expect.stringContaining("custom-gateway.service"), + "Other gateway-like services detected", + ); + }); + + it("reports active non-legacy Linux gateway-like services", async () => { + mockProcessPlatform("linux"); + mocks.findExtraGatewayServices.mockResolvedValue([ + { + platform: "linux", + label: "custom-gateway.service", + detail: "unit: /etc/systemd/system/custom-gateway.service", + scope: "system", + legacy: false, + }, + ]); + mocks.isSystemdUnitActive.mockResolvedValue(true); + + await maybeScanExtraGatewayServices({ deep: true }, makeDoctorIo(), makeDoctorPrompts()); + + expect(mocks.isSystemdUnitActive).toHaveBeenCalledWith( + process.env, + "custom-gateway.service", + "system", + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("custom-gateway.service"), + "Other gateway-like services detected", + ); + }); + it("removes legacy Linux user systemd services", async () => { + mockProcessPlatform("linux"); mocks.findExtraGatewayServices.mockResolvedValue([ { platform: "linux", diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index b4c8b8bf3f0..ba0223c9371 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -21,7 +21,11 @@ import { } from "../daemon/service-audit.js"; import { readManagedServiceEnvKeysFromEnvironment } from "../daemon/service-managed-env.js"; import { resolveGatewayService, type GatewayServiceCommandConfig } from "../daemon/service.js"; -import { uninstallLegacySystemdUnits } from "../daemon/systemd.js"; +import { + isSystemdUnitActive, + uninstallLegacySystemdUnits, + type SystemdUnitScope, +} from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -41,6 +45,10 @@ import { } from "./doctor-service-repair-policy.js"; const execFileAsync = promisify(execFile); +const EXECSTART_REPAIR_CODES = new Set([ + SERVICE_AUDIT_CODES.gatewayCommandMissing, + SERVICE_AUDIT_CODES.gatewayEntrypointMismatch, +]); function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDaemonRuntime { const first = programArguments?.[0]; @@ -142,6 +150,73 @@ function extractDetailPath(detail: string, prefix: string): string | null { return value.length > 0 ? value : null; } +function isExecStartRepairIssue(issue: { code: string }): boolean { + return EXECSTART_REPAIR_CODES.has(issue.code); +} + +function resolveSystemdScopeFromServicePath(sourcePath: string | undefined): SystemdUnitScope { + const normalized = sourcePath?.replaceAll("\\", "/") ?? ""; + return normalized.startsWith("/etc/systemd/") || + normalized.startsWith("/usr/lib/systemd/") || + normalized.startsWith("/lib/systemd/") + ? "system" + : "user"; +} + +function resolveSystemdUnitNameFromServicePath(sourcePath: string | undefined): string { + const base = sourcePath ? path.posix.basename(sourcePath.replaceAll("\\", "/")) : ""; + return base.endsWith(".service") ? base : "openclaw-gateway.service"; +} + +async function suppressRunningSystemdExecStartRepairs(params: { + command: GatewayServiceCommandConfig; + issues: { code: string }[]; +}): Promise { + if (process.platform !== "linux") { + return false; + } + if (!params.issues.some(isExecStartRepairIssue)) { + return false; + } + const unitName = resolveSystemdUnitNameFromServicePath(params.command.sourcePath); + const scope = resolveSystemdScopeFromServicePath(params.command.sourcePath); + if (!(await isSystemdUnitActive(process.env, unitName, scope))) { + return false; + } + const before = params.issues.length; + params.issues.splice( + 0, + params.issues.length, + ...params.issues.filter((issue) => !isExecStartRepairIssue(issue)), + ); + if (params.issues.length !== before) { + note( + `Gateway service ${unitName} is running; skipped command/entrypoint rewrites for this doctor pass.`, + "Gateway service config", + ); + } + return true; +} + +async function filterInactiveExtraGatewayServices( + services: ExtraGatewayService[], +): Promise { + if (process.platform !== "linux") { + return services; + } + const activeOrLegacy: ExtraGatewayService[] = []; + for (const svc of services) { + if (svc.platform !== "linux" || svc.legacy === true) { + activeOrLegacy.push(svc); + continue; + } + if (await isSystemdUnitActive(process.env, svc.label, svc.scope)) { + activeOrLegacy.push(svc); + } + } + return activeOrLegacy; +} + async function cleanupLegacyLaunchdService(params: { label: string; plistPath: string; @@ -379,6 +454,11 @@ export async function maybeRepairGatewayServiceConfig( }); } + const serviceRewriteBlocked = await suppressRunningSystemdExecStartRepairs({ + command, + issues: audit.issues, + }); + if (audit.issues.length === 0) { return; } @@ -410,6 +490,14 @@ export async function maybeRepairGatewayServiceConfig( return; } + if (serviceRewriteBlocked) { + note( + "Gateway service is running; leaving supervisor metadata unchanged. Stop the service first or use `openclaw gateway install --force` when you want to replace the active launcher.", + "Gateway service config", + ); + return; + } + const repair = needsAggressive ? await prompter.confirmAggressiveAutoFix({ message: "Overwrite gateway service config with current defaults now?", @@ -492,9 +580,10 @@ export async function maybeScanExtraGatewayServices( runtime: RuntimeEnv, prompter: DoctorPrompter, ) { - const extraServices = await findExtraGatewayServices(process.env, { + const detectedExtraServices = await findExtraGatewayServices(process.env, { deep: options.deep, }); + const extraServices = await filterInactiveExtraGatewayServices(detectedExtraServices); if (extraServices.length === 0) { return; } diff --git a/src/commands/doctor-service-audit.test-helpers.ts b/src/commands/doctor-service-audit.test-helpers.ts index c4aca110696..af697037ca8 100644 --- a/src/commands/doctor-service-audit.test-helpers.ts +++ b/src/commands/doctor-service-audit.test-helpers.ts @@ -3,6 +3,7 @@ import type { GatewayServiceEnvironmentValueSource } from "../daemon/service-typ import { normalizeOptionalString } from "../shared/string-coerce.js"; export const testServiceAuditCodes = { + gatewayCommandMissing: "gateway-command-missing", gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", gatewayManagedEnvEmbedded: "gateway-managed-env-embedded", gatewayPortMismatch: "gateway-port-mismatch", diff --git a/src/daemon/inspect.test.ts b/src/daemon/inspect.test.ts index 5a215b7e2eb..94a6a63dd12 100644 --- a/src/daemon/inspect.test.ts +++ b/src/daemon/inspect.test.ts @@ -53,6 +53,24 @@ 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(); @@ -70,6 +88,15 @@ describe("detectMarkerLineWithGateway", () => { const contents = `[Service]\nExecStart=/usr/bin/node /opt/openclaw/dist/entry.js \\\n gateway --port 18789\n`; expect(detectMarkerLineWithGateway(contents)).toBe("openclaw"); }); + + 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("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem", () => { @@ -135,6 +162,140 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem" } }, ); + + it.skipIf(!isLinux)( + "does not report companion units that only depend on the gateway", + async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); + try { + 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).toEqual([]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(!isLinux)( + "reports custom-named gateway units that execute openclaw gateway", + async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); + const unitPath = path.join(systemdDir, "custom-openclaw.service"); + try { + await fs.mkdir(systemdDir, { recursive: true }); + await fs.writeFile(unitPath, CUSTOM_OPENCLAW_GATEWAY_CONTENTS); + const result = await findExtraGatewayServices({ HOME: tmpHome }); + expect(result).toEqual([ + { + platform: "linux", + label: "custom-openclaw.service", + detail: `unit: ${unitPath}`, + scope: "user", + marker: "openclaw", + legacy: false, + }, + ]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }, + ); +}); + +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 = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); + try { + 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).toEqual([]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); + + it("does not report LaunchAgent companions that only pass gateway-named options", async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); + try { + 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).toEqual([]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); + + it("reports custom LaunchAgents that execute openclaw gateway", async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const launchdDir = path.join(tmpHome, "Library", "LaunchAgents"); + const plistPath = path.join(launchdDir, "com.example.openclaw-gateway.plist"); + try { + 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, + }, + ]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); }); describe("findExtraGatewayServices (win32)", () => { diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 8945d8dd1d2..f300076d48e 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -10,6 +10,7 @@ import { } from "./constants.js"; import { resolveHomeDir } from "./paths.js"; import { execSchtasks } from "./schtasks-exec.js"; +import { parseSystemdExecStart } from "./systemd-unit.js"; export type ExtraGatewayService = { platform: "darwin" | "linux" | "win32"; @@ -25,6 +26,19 @@ export type FindExtraGatewayServicesOptions = { }; const EXTRA_MARKERS = ["openclaw", "clawdbot"] as const; +const SYSTEMD_REFERENCE_ONLY_KEYS = new Set([ + "after", + "before", + "bindsto", + "conflicts", + "partof", + "propagatesreloadto", + "reloadpropagatedfrom", + "requisite", + "requires", + "upholds", + "wants", +]); export function renderGatewayServiceCleanupHints( env: Record = process.env as Record, @@ -63,15 +77,42 @@ function detectMarker(content: string): Marker | null { return null; } +function hasGatewaySubcommandArg(args: string[]): boolean { + return args.some((arg) => { + const normalized = normalizeLowercaseStringOrEmpty(arg); + return normalized === "gateway" || /(^|\s)gateway(\s|$)/.test(normalized); + }); +} + export function detectMarkerLineWithGateway(contents: string): Marker | null { // Join line continuations (trailing backslash) into single lines const lower = normalizeLowercaseStringOrEmpty(contents.replace(/\\\r?\n\s*/g, " ")); for (const line of lower.split(/\r?\n/)) { - if (!line.includes("gateway")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) { + continue; + } + const assignment = trimmed.indexOf("="); + if (assignment > 0) { + const key = trimmed.slice(0, assignment).trim(); + if (SYSTEMD_REFERENCE_ONLY_KEYS.has(key)) { + continue; + } + if ( + key === "execstart" && + !hasGatewaySubcommandArg(parseSystemdExecStart(trimmed.slice(assignment + 1).trim())) + ) { + continue; + } + if (key !== "execstart") { + continue; + } + } + if (!trimmed.includes("gateway")) { continue; } for (const marker of EXTRA_MARKERS) { - if (line.includes(marker)) { + if (trimmed.includes(marker)) { return marker; } } @@ -95,12 +136,59 @@ function hasGatewayServiceMarker(content: string): boolean { ); } +function extractPlistKeyBlock( + contents: string, + key: string, + tag: "array" | "string", +): string | null { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `${escapedKey}<\\/key>\\s*<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, + "i", + ); + const match = contents.match(pattern); + return match?.[1]?.trim() || null; +} + +function extractPlistStringValues( + contents: string, + key: string, + tag: "array" | "string", +): string[] { + const block = extractPlistKeyBlock(contents, key, tag); + if (!block) { + return []; + } + if (tag === "string") { + return [block]; + } + return Array.from(block.matchAll(/([\s\S]*?)<\/string>/gi)) + .map((match) => match[1]?.trim() ?? "") + .filter(Boolean); +} + +function detectLaunchdGatewayExecutionMarker(contents: string): Marker | null { + const program = extractPlistStringValues(contents, "Program", "string"); + const programArguments = extractPlistStringValues(contents, "ProgramArguments", "array"); + if (!hasGatewaySubcommandArg(programArguments)) { + return null; + } + const launchCommand = normalizeLowercaseStringOrEmpty( + [...program, ...programArguments].filter(Boolean).join("\n"), + ); + for (const marker of EXTRA_MARKERS) { + if (launchCommand.includes(marker)) { + return marker; + } + } + return null; +} + function isOpenClawGatewayLaunchdService(label: string, contents: string): boolean { if (hasGatewayServiceMarker(contents)) { return true; } - const lowerContents = normalizeLowercaseStringOrEmpty(contents); - if (!lowerContents.includes("gateway")) { + if (detectLaunchdGatewayExecutionMarker(contents) !== "openclaw") { return false; } return label.startsWith("ai.openclaw."); @@ -206,21 +294,16 @@ async function scanLaunchdDir(params: { }); for (const { name: labelFromName, fullPath, contents } of candidates) { - const marker = detectMarker(contents); const label = tryExtractPlistLabel(contents) ?? labelFromName; + const legacyLabel = isLegacyLabel(labelFromName) || isLegacyLabel(label); + const executionMarker = detectLaunchdGatewayExecutionMarker(contents); + const marker = + hasGatewayServiceMarker(contents) || executionMarker === "openclaw" + ? "openclaw" + : executionMarker === "clawdbot" || legacyLabel || detectMarker(contents) === "clawdbot" + ? "clawdbot" + : null; if (!marker) { - const legacyLabel = isLegacyLabel(labelFromName) || isLegacyLabel(label); - if (!legacyLabel) { - continue; - } - results.push({ - platform: "darwin", - label, - detail: `plist: ${fullPath}`, - scope: params.scope, - marker: "clawdbot", - legacy: true, - }); continue; } if (isIgnoredLaunchdLabel(label)) { @@ -255,7 +338,9 @@ async function scanSystemdDir(params: { }); for (const { entry, name, fullPath, contents } of candidates) { - const marker = detectMarkerLineWithGateway(contents); + const marker = hasGatewayServiceMarker(contents) + ? "openclaw" + : detectMarkerLineWithGateway(contents); if (!marker) { continue; } diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 036f0adf34d..bb12a8ca0eb 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -20,6 +20,7 @@ import { installSystemdService, isNonFatalSystemdInstallProbeError, isSystemdServiceEnabled, + isSystemdUnitActive, isSystemdUserServiceAvailable, parseSystemdShow, readSystemdServiceExecStart, @@ -401,6 +402,35 @@ describe("isSystemdServiceEnabled", () => { }); }); +describe("isSystemdUnitActive", () => { + beforeEach(() => { + vi.restoreAllMocks(); + execFileMock.mockReset(); + }); + + it("checks user-scoped units through the user systemd manager", async () => { + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "is-active", "--quiet", GATEWAY_SERVICE); + cb(null, "", ""); + }); + + await expect(isSystemdUnitActive({ HOME: TEST_MANAGED_HOME }, GATEWAY_SERVICE)).resolves.toBe( + true, + ); + }); + + it("checks system-scoped units without the user manager", async () => { + execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["is-active", "--quiet", GATEWAY_SERVICE]); + cb(createExecFileError("inactive", { code: 3 }), "", ""); + }); + + await expect( + isSystemdUnitActive({ HOME: TEST_MANAGED_HOME }, GATEWAY_SERVICE, "system"), + ).resolves.toBe(false); + }); +}); + describe("isNonFatalSystemdInstallProbeError", () => { it("matches wrapper-only WSL install probe failures", () => { expect( diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 634bfedbe4b..710eeeb9556 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -275,6 +275,8 @@ export function parseSystemdShow(output: string): SystemdServiceInfo { return info; } +export type SystemdUnitScope = "system" | "user"; + async function execSystemctl( args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { @@ -468,6 +470,20 @@ export async function isSystemdUserServiceAvailable( return !isSystemdUserScopeUnavailable(detail); } +export async function isSystemdUnitActive( + env: GatewayServiceEnv, + unitName: string, + scope: SystemdUnitScope = "user", +): Promise { + const normalizedUnit = unitName.trim(); + if (!normalizedUnit) { + return false; + } + const args = ["is-active", "--quiet", normalizedUnit]; + const res = scope === "system" ? await execSystemctl(args) : await execSystemctlUser(env, args); + return res.code === 0; +} + async function assertSystemdAvailable(env: GatewayServiceEnv = process.env as GatewayServiceEnv) { const res = await execSystemctlUser(env, ["status"]); if (res.code === 0) {