From a24f8141da18f3a280b18f2b95770ae6545daea9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 03:29:31 -0700 Subject: [PATCH] refactor(daemon): decouple service paths from display text (#162597) --- src/cli/daemon-cli/status.print.test.ts | 18 ++- src/cli/daemon-cli/status.print.ts | 1 + .../doctor-gateway-legacy-services.test.ts | 6 + src/commands/doctor-gateway-services.ts | 10 +- src/daemon/inspect-files.ts | 2 + src/daemon/inspect.cleanup-hints.test.ts | 138 ++++++++++++++++ src/daemon/inspect.test.ts | 152 +++--------------- src/daemon/inspect.ts | 6 +- src/daemon/managed-gateway-bindings.test.ts | 12 +- src/daemon/managed-gateway-bindings.ts | 11 +- src/daemon/service.systemd-scope.test.ts | 4 + src/daemon/systemd-scope.identity.test.ts | 21 ++- src/daemon/systemd-scope.ts | 3 +- src/daemon/systemd.test.ts | 17 +- .../live-gateway-dist-fence.absence.test.ts | 4 +- 15 files changed, 229 insertions(+), 176 deletions(-) create mode 100644 src/daemon/inspect.cleanup-hints.test.ts diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index eefcdb553da7..66a49b3b4b05 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -159,6 +159,13 @@ describe("printDaemonStatus", () => { it("preserves Gateway metadata and input while redacting private definitions in JSON", () => { const server = { version: "2026.5.6", buildId: "build-2026.5.6", connId: "conn-1" }; + const extraService: ExtraGatewayService = { + platform: "linux", + label: "sibling.service", + detail: "unit: /etc/systemd/system/sibling.service", + sourcePath: "/etc/systemd/system/sibling.service", + scope: "system", + }; const command: GatewayServiceCommandConfig = { programArguments: ["node"], environment: { @@ -175,12 +182,21 @@ describe("printDaemonStatus", () => { }; const original = structuredClone(command); printDaemonStatus( - { service: { command }, rpc: { ok: true, server } }, + { service: { command }, rpc: { ok: true, server }, extraServices: [extraService] }, { json: true, deep: true }, ); expect(runtime.writeJson).toHaveBeenCalledOnce(); const payload = runtime.writeJson.mock.calls[0]?.[0]; expect(payload).toHaveProperty("rpc.server", server); + expect(payload).toHaveProperty("extraServices", [ + { + platform: "linux", + label: "sibling.service", + detail: "unit: /etc/systemd/system/sibling.service", + scope: "system", + }, + ]); + expect(extraService.sourcePath).toBe("/etc/systemd/system/sibling.service"); expect(payload).not.toHaveProperty("service.command.managedDefinition"); expect(payload).not.toHaveProperty("service.command.managedOverrides"); expect(payload).not.toHaveProperty("service.command.definitionPaths"); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 2936a0965fbe..556452361eef 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -76,6 +76,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d if (opts.json) { defaultRuntime.writeJson({ ...status, + extraServices: status.extraServices.map(({ sourcePath: _sourcePath, ...service }) => service), service: projectDaemonServiceForJson(status.service, { includeDefinitionPaths: false }), }); return; diff --git a/src/commands/doctor-gateway-legacy-services.test.ts b/src/commands/doctor-gateway-legacy-services.test.ts index d35999dac867..e837b0b77c90 100644 --- a/src/commands/doctor-gateway-legacy-services.test.ts +++ b/src/commands/doctor-gateway-legacy-services.test.ts @@ -37,6 +37,7 @@ function setupLegacyMacService() { platform: "darwin", label: LEGACY_MAC_LABEL, detail: `plist: ${LEGACY_MAC_PLIST}`, + sourcePath: LEGACY_MAC_PLIST, scope: "user", legacy: true, }, @@ -108,6 +109,7 @@ describe("maybeScanExtraGatewayServices", () => { platform: "linux" as const, label: "custom-gateway.service", detail: `unit: ${unitPath}`, + sourcePath: unitPath, scope, legacy: false, }; @@ -183,6 +185,7 @@ describe("maybeScanExtraGatewayServices", () => { platform: "darwin" as const, label: "com.example.openclaw-gateway", detail: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist", + sourcePath: "/Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist", scope: "user" as const, legacy: false, }; @@ -259,6 +262,7 @@ describe("maybeScanExtraGatewayServices", () => { platform: "linux", label: "clawdbot-gateway.service", detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service", + sourcePath: "/home/test/.config/systemd/user/clawdbot-gateway.service", scope: "user", legacy: true, }, @@ -299,6 +303,7 @@ describe("maybeScanExtraGatewayServices", () => { platform: "linux", label, detail: `unit: /home/test/.config/systemd/user/${label}`, + sourcePath: `/home/test/.config/systemd/user/${label}`, scope: "user", marker: "clawdbot", legacy: true, @@ -514,6 +519,7 @@ describe("maybeScanExtraGatewayServices", () => { platform: "linux", label: "clawdbot-gateway.service", detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service", + sourcePath: "/home/test/.config/systemd/user/clawdbot-gateway.service", scope: "user", legacy: true, }, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 4cc902a4a4fb..30025d3d405b 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -116,14 +116,6 @@ async function confirmLegacyLaunchdServiceUnloaded(serviceTarget: string): Promi return false; } -function extractDetailPath(detail: string, prefix: string): string | null { - if (!detail.startsWith(prefix)) { - return null; - } - const value = detail.slice(prefix.length).trim(); - return value.length > 0 ? value : null; -} - async function filterInactiveExtraGatewayServices( services: ExtraGatewayService[], ): Promise { @@ -205,7 +197,7 @@ async function cleanupLegacyDarwinServices( const failed: string[] = []; for (const svc of services) { - const plistPath = extractDetailPath(svc.detail, "plist:"); + const plistPath = svc.sourcePath; if (!plistPath) { failed.push(`${svc.label} (missing plist path)`); continue; diff --git a/src/daemon/inspect-files.ts b/src/daemon/inspect-files.ts index 77566a6ab7e8..263132515fb1 100644 --- a/src/daemon/inspect-files.ts +++ b/src/daemon/inspect-files.ts @@ -13,6 +13,7 @@ export type ExtraGatewayService = { platform: "darwin" | "linux" | "win32"; label: string; detail: string; + sourcePath?: string; scope: "user" | "system"; marker?: "openclaw" | "clawdbot"; legacy?: boolean; @@ -116,6 +117,7 @@ export async function scanSystemdDir(params: { platform: "linux", label: entry, detail: `unit: ${fullPath}`, + sourcePath: fullPath, scope: params.scope, marker, legacy: marker !== "openclaw", diff --git a/src/daemon/inspect.cleanup-hints.test.ts b/src/daemon/inspect.cleanup-hints.test.ts new file mode 100644 index 000000000000..358ca8b9a6d1 --- /dev/null +++ b/src/daemon/inspect.cleanup-hints.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { renderGatewayServiceCleanupHints } from "./inspect.js"; + +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", + sourcePath: "/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", + sourcePath: "/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", + sourcePath: "/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", + sourcePath: "/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", + sourcePath: "/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", + sourcePath: "/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", + sourcePath: "/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, sourcePath, scope, firstHint, secondHint }) => { + expect( + renderGatewayServiceCleanupHints([ + { + platform, + label: serviceName, + detail: source, + sourcePath, + scope, + }, + ]), + ).toEqual([firstHint, secondHint]); + }, + ); + + it("inspects the detected Windows scheduled task without suggesting removal", () => { + expect( + renderGatewayServiceCleanupHints([ + { + platform: "win32", + label: "\\OpenClaw Gateway Backup", + detail: "task: \\OpenClaw Gateway Backup", + scope: "system", + }, + ]), + ).toEqual(['schtasks /Query /TN "\\OpenClaw Gateway Backup" /V /FO LIST']); + }); + + 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"]); + }); +}); diff --git a/src/daemon/inspect.test.ts b/src/daemon/inspect.test.ts index 83143859f898..40250c2664fe 100644 --- a/src/daemon/inspect.test.ts +++ b/src/daemon/inspect.test.ts @@ -131,131 +131,6 @@ describe("detectMarkerLineWithGateway", () => { }); }); -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("inspects the detected Windows scheduled task without suggesting removal", () => { - expect( - renderGatewayServiceCleanupHints([ - { - platform: "win32", - label: "\\OpenClaw Gateway Backup", - detail: "task: \\OpenClaw Gateway Backup", - scope: "system", - }, - ]), - ).toEqual(['schtasks /Query /TN "\\OpenClaw Gateway Backup" /V /FO LIST']); - }); - - 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. @@ -299,6 +174,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem" platform: "linux", label: "clawdbot-gateway.service", detail: `unit: ${unitPath}`, + sourcePath: unitPath, scope: "user", marker: "clawdbot", legacy: true, @@ -343,6 +219,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem" platform: "linux", label: "clawdbot-gateway.service", detail: `unit: ${unitPath}`, + sourcePath: unitPath, scope: "user", marker: "clawdbot", legacy: true, @@ -382,6 +259,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem" platform: "linux", label: "custom-openclaw.service", detail: `unit: ${unitPath}`, + sourcePath: unitPath, scope: "user", marker: "openclaw", legacy: false, @@ -507,6 +385,7 @@ describe("findExtraGatewayServices (darwin / scanLaunchdDir) — real filesystem platform: "darwin", label: "com.example.openclaw-gateway", detail: `plist: ${plistPath}`, + sourcePath: plistPath, scope: "user", marker: "openclaw", legacy: false, @@ -722,6 +601,7 @@ describe("managed Gateway inventory projections", () => { label: "openclaw@.service", scope: "system", detail: `unit: ${path.join("/etc/systemd/system", "openclaw@.service")}`, + sourcePath: path.join("/etc/systemd/system", "openclaw@.service"), marker: "openclaw", legacy: false, }); @@ -761,7 +641,7 @@ describe("managed Gateway inventory projections", () => { } }); - it("finds Gateways in XDG, runtime, and systemd control load paths before build admission", async () => { + it("keeps discovered native selectors independent of display details", async () => { Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); const home = tempDirs.make("managed-systemd-load-paths-", os.tmpdir()); const write = isolateNativeRoots(home); @@ -814,6 +694,25 @@ describe("managed Gateway inventory projections", () => { "system-generated-gateway.service", "transient-gateway.service", ]); + const inventory = await import("./inspect.js"); + for (const service of result.services) { + service.detail = "Discovered Gateway"; + } + vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue(result); + const { discoverManagedGatewayBindings } = await import("./managed-gateway-bindings.js"); + const bindings = await discoverManagedGatewayBindings({ HOME: home }); + expect(bindings).toHaveLength(7); + expect(bindings.map((binding) => binding.systemdReadTarget?.unitPath)).toEqual( + expect.arrayContaining([ + path.join(configHome, "systemd/user/config-gateway.service"), + path.join(dataHome, "systemd/user/data-gateway.service"), + "/etc/systemd/system.control/system-gateway.service", + path.join(runtimeDir, "systemd/generator/generated-gateway.service"), + path.join(runtimeDir, "systemd/transient/transient-gateway.service"), + "/run/systemd/user/run-gateway.service", + "/run/systemd/generator/system-generated-gateway.service", + ]), + ); }); it.each([ @@ -864,6 +763,7 @@ describe("managed Gateway inventory projections", () => { platform: "darwin", label, detail: `plist: ${file}`, + sourcePath: file, scope: "system", marker: "openclaw", legacy: false, diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 4ca609020920..23bdc963c5bf 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -89,9 +89,7 @@ export function renderGatewayServiceCleanupHints( for (const service of services) { switch (service.platform) { case "darwin": { - const plistPath = service.detail.startsWith("plist:") - ? service.detail.slice("plist:".length).trim() - : undefined; + const plistPath = service.sourcePath; // Global LaunchAgents still run in a GUI domain; only LaunchDaemons // belong to the system domain regardless of their shared file scope. const domain = @@ -184,6 +182,7 @@ async function scanLaunchdDir(params: { platform: "darwin", label, detail: `plist: ${fullPath}`, + sourcePath: fullPath, scope: params.scope, marker, legacy: marker !== "openclaw" || isLegacyLabel(label), @@ -506,6 +505,7 @@ async function scanGatewayServices( platform: "linux", label: unit.name, detail: `unit: ${unit.fragmentPath}`, + sourcePath: unit.fragmentPath, scope, marker: marker ?? "openclaw", legacy: marker === "clawdbot", diff --git a/src/daemon/managed-gateway-bindings.test.ts b/src/daemon/managed-gateway-bindings.test.ts index 03dd86c5da6e..48f7a0e80053 100644 --- a/src/daemon/managed-gateway-bindings.test.ts +++ b/src/daemon/managed-gateway-bindings.test.ts @@ -183,6 +183,7 @@ it.each([ platform: "darwin", label, detail: `plist: ${locations.local.plist}`, + sourcePath: locations.local.plist, scope: "user", marker: "openclaw", }, @@ -190,6 +191,7 @@ it.each([ platform: "darwin", label, detail: `plist: ${locations.global.plist}`, + sourcePath: locations.global.plist, scope: "system", marker: "openclaw", }, @@ -197,6 +199,7 @@ it.each([ platform: "darwin", label, detail: `plist: ${systemPlist}`, + sourcePath: systemPlist, scope: "system", marker: "openclaw", }, @@ -353,7 +356,13 @@ it.each([ if (scenario.loaded === "system") { expect( inventory.renderGatewayServiceCleanupHints([ - { platform: "darwin", label, detail: `plist: ${systemPlist}`, scope: "system" }, + { + platform: "darwin", + label, + detail: `plist: ${systemPlist}`, + sourcePath: systemPlist, + scope: "system", + }, ]), ).toContain(`sudo launchctl bootout system/${label}`); } @@ -380,6 +389,7 @@ it.each([false, true])( scope: "user" as const, label: "custom-sibling.service", detail: "unit: /synthetic/custom-sibling.service", + sourcePath: "/synthetic/custom-sibling.service", }; vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue({ services: [sibling, sibling], diff --git a/src/daemon/managed-gateway-bindings.ts b/src/daemon/managed-gateway-bindings.ts index 57d887c2b796..9126b2dd99cf 100644 --- a/src/daemon/managed-gateway-bindings.ts +++ b/src/daemon/managed-gateway-bindings.ts @@ -98,18 +98,11 @@ function profileEnvFields(profile: string): GatewayServiceEnv { return profile === "default" ? {} : { OPENCLAW_PROFILE: profile }; } -function detailPath(prefix: string, detail: string): string | undefined { - if (!detail.startsWith(prefix)) { - return undefined; - } - return detail.slice(prefix.length).trim(); -} - function bindingFromSystemdService( svc: ExtraGatewayService, env: Record, ): ManagedGatewayBinding { - const unitPath = detailPath("unit:", svc.detail); + const unitPath = svc.sourcePath; const unitName = resolveSystemdTemplateInstanceName(svc.label, { OPENCLAW_SYSTEMD_UNIT: svc.label, }); @@ -125,7 +118,7 @@ function bindingFromLaunchdService( svc: ExtraGatewayService, env: Record, ): ManagedGatewayBinding { - const plistPath = detailPath("plist:", svc.detail); + const plistPath = svc.sourcePath; return { scope: svc.scope, ...(plistPath ? { launchAgentPlistPath: plistPath } : {}), diff --git a/src/daemon/service.systemd-scope.test.ts b/src/daemon/service.systemd-scope.test.ts index 01eab06896d1..1ceaac35f4b0 100644 --- a/src/daemon/service.systemd-scope.test.ts +++ b/src/daemon/service.systemd-scope.test.ts @@ -156,6 +156,7 @@ it.each([ marker: "openclaw" as const, label: otherName, detail: `unit: ${otherPath}`, + sourcePath: otherPath, }, ] : []), @@ -165,6 +166,7 @@ it.each([ marker: "openclaw", label: file, detail: `unit: ${target.unitPath}`, + sourcePath: target.unitPath, }, ]); vi.spyOn(fs, "access").mockRejectedValue( @@ -511,6 +513,7 @@ it("reads the system template instance while a separate user Gateway is installe marker: "openclaw", label: "openclaw@.service", detail: `unit: ${templateUnit}`, + sourcePath: templateUnit, }, ]); mockProcessPlatform("linux"); @@ -651,6 +654,7 @@ it("findSystemdGatewayInstallation expands a system template to this account's i platform: "linux", label: "openclaw@.service", detail: "unit: /etc/systemd/system/openclaw@.service", + sourcePath: "/etc/systemd/system/openclaw@.service", scope: "system", marker: "openclaw", }, diff --git a/src/daemon/systemd-scope.identity.test.ts b/src/daemon/systemd-scope.identity.test.ts index 7ee6e08db2ba..647d8cc65012 100644 --- a/src/daemon/systemd-scope.identity.test.ts +++ b/src/daemon/systemd-scope.identity.test.ts @@ -4,18 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { findInstalledSystemdGatewayScope, isNonFatalSystemdInstallProbeError } from "./systemd.js"; const findSystemGatewayServicesMock = vi.hoisted(() => - vi.fn< - () => Promise< - Array<{ - platform: "linux"; - label: string; - detail: string; - scope: "user" | "system"; - marker?: "openclaw" | "clawdbot"; - legacy?: boolean; - }> - > - >(async () => []), + vi.fn(async () => []), ); vi.mock("./inspect.js", () => ({ @@ -81,6 +70,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service", + sourcePath: "/etc/systemd/system/openclaw.service", scope: "system", marker: "openclaw", }, @@ -100,6 +90,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw-darlene.service", detail: "unit: /etc/systemd/system/openclaw-darlene.service", + sourcePath: "/etc/systemd/system/openclaw-darlene.service", scope: "system", marker: "openclaw", }, @@ -118,6 +109,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw-gateway-lisa.service", detail: "unit: /etc/systemd/system/openclaw-gateway-lisa.service", + sourcePath: "/etc/systemd/system/openclaw-gateway-lisa.service", scope: "system", marker: "openclaw", }, @@ -136,6 +128,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "my-custom-gateway.service", detail: "unit: /etc/systemd/system/my-custom-gateway.service", + sourcePath: "/etc/systemd/system/my-custom-gateway.service", scope: "system", marker: "openclaw", }, @@ -216,6 +209,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label, detail: `unit: /etc/systemd/system/${label}`, + sourcePath: `/etc/systemd/system/${label}`, scope: "system", marker: "openclaw", }, @@ -243,6 +237,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw@.service", detail: "unit: /etc/systemd/system/openclaw@.service", + sourcePath: "/etc/systemd/system/openclaw@.service", scope: "system", marker: "openclaw", }, @@ -265,6 +260,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw@other.service", detail: "unit: /etc/systemd/system/openclaw@other.service", + sourcePath: "/etc/systemd/system/openclaw@other.service", scope: "system", marker: "openclaw", }, @@ -303,6 +299,7 @@ describe("systemd gateway identity (openclaw#119648)", () => { platform: "linux", label: "openclaw-darlene.service", detail: "unit: /etc/systemd/system/openclaw-darlene.service", + sourcePath: "/etc/systemd/system/openclaw-darlene.service", scope: "system", marker: "openclaw", }, diff --git a/src/daemon/systemd-scope.ts b/src/daemon/systemd-scope.ts index 3ad89084b677..54129b479149 100644 --- a/src/daemon/systemd-scope.ts +++ b/src/daemon/systemd-scope.ts @@ -460,8 +460,7 @@ async function findMarkerOwnedSystemSystemdUnit( ) { continue; } - const match = /^unit:\s*(.+)$/.exec(svc.detail.trim()); - const unitPath = match?.[1]?.trim(); + const unitPath = svc.sourcePath; if (unitPath) { const target: SystemdServiceReadTarget = { scope: "system", diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 8b7c8fa1481f..66aaf99d38bf 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -27,18 +27,7 @@ const assertNoSystemSystemdOwnershipMock = vi.hoisted(() => vi.fn<(unitName: string, timeoutMs?: number) => Promise>(async () => {}), ); const findSystemGatewayServicesMock = vi.hoisted(() => - vi.fn< - () => Promise< - Array<{ - platform: "linux"; - label: string; - detail: string; - scope: "user" | "system"; - marker?: "openclaw" | "clawdbot"; - legacy?: boolean; - }> - > - >(async () => []), + vi.fn(async () => []), ); vi.mock("./inspect.js", () => ({ @@ -596,6 +585,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => { platform: "linux", label: "openclaw-rescue.service", detail: "unit: /etc/systemd/system/openclaw-rescue.service", + sourcePath: "/etc/systemd/system/openclaw-rescue.service", scope: "system", marker: "openclaw", }, @@ -640,6 +630,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => { platform: "linux", label: "openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service", + sourcePath: "/etc/systemd/system/openclaw.service", scope: "system", marker: "openclaw", }, @@ -660,6 +651,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => { platform: "linux", label: "openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service", + sourcePath: "/etc/systemd/system/openclaw.service", scope: "system", marker: "openclaw", }, @@ -678,6 +670,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => { platform: "linux", label: "openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service", + sourcePath: "/etc/systemd/system/openclaw.service", scope: "system", marker: "openclaw", }, diff --git a/test/scripts/live-gateway-dist-fence.absence.test.ts b/test/scripts/live-gateway-dist-fence.absence.test.ts index 60ffee6bcbe4..b29e5fe73b36 100644 --- a/test/scripts/live-gateway-dist-fence.absence.test.ts +++ b/test/scripts/live-gateway-dist-fence.absence.test.ts @@ -29,7 +29,9 @@ it.each([ const label = "org.example.fence-sibling"; const plist = path.join(home, "Library", "LaunchAgents", `${label}.plist`); vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue({ - services: [{ platform: "darwin", scope: "user", label, detail: `plist: ${plist}` }], + services: [ + { platform: "darwin", scope: "user", label, detail: `plist: ${plist}`, sourcePath: plist }, + ], errors: [], }); const native = vi.spyOn(launchdExec, "execLaunchctl").mockImplementation(async (args) => {