refactor(daemon): decouple service paths from display text (#162597)

This commit is contained in:
Peter Steinberger 2026-10-01 03:29:31 -07:00 • committed by GitHub
parent 9505aedf41
commit a24f8141da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 229 additions and 176 deletions

View file

@ -159,6 +159,13 @@ describe("printDaemonStatus", () => {
it("preserves Gateway metadata and input while redacting private definitions in JSON", () => { 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 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 = { const command: GatewayServiceCommandConfig = {
programArguments: ["node"], programArguments: ["node"],
environment: { environment: {
@ -175,12 +182,21 @@ describe("printDaemonStatus", () => {
}; };
const original = structuredClone(command); const original = structuredClone(command);
printDaemonStatus( printDaemonStatus(
{ service: { command }, rpc: { ok: true, server } }, { service: { command }, rpc: { ok: true, server }, extraServices: [extraService] },
{ json: true, deep: true }, { json: true, deep: true },
); );
expect(runtime.writeJson).toHaveBeenCalledOnce(); expect(runtime.writeJson).toHaveBeenCalledOnce();
const payload = runtime.writeJson.mock.calls[0]?.[0]; const payload = runtime.writeJson.mock.calls[0]?.[0];
expect(payload).toHaveProperty("rpc.server", server); 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.managedDefinition");
expect(payload).not.toHaveProperty("service.command.managedOverrides"); expect(payload).not.toHaveProperty("service.command.managedOverrides");
expect(payload).not.toHaveProperty("service.command.definitionPaths"); expect(payload).not.toHaveProperty("service.command.definitionPaths");

View file

@ -76,6 +76,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
if (opts.json) { if (opts.json) {
defaultRuntime.writeJson({ defaultRuntime.writeJson({
...status, ...status,
extraServices: status.extraServices.map(({ sourcePath: _sourcePath, ...service }) => service),
service: projectDaemonServiceForJson(status.service, { includeDefinitionPaths: false }), service: projectDaemonServiceForJson(status.service, { includeDefinitionPaths: false }),
}); });
return; return;

View file

@ -37,6 +37,7 @@ function setupLegacyMacService() {
platform: "darwin", platform: "darwin",
label: LEGACY_MAC_LABEL, label: LEGACY_MAC_LABEL,
detail: `plist: ${LEGACY_MAC_PLIST}`, detail: `plist: ${LEGACY_MAC_PLIST}`,
sourcePath: LEGACY_MAC_PLIST,
scope: "user", scope: "user",
legacy: true, legacy: true,
}, },
@ -108,6 +109,7 @@ describe("maybeScanExtraGatewayServices", () => {
platform: "linux" as const, platform: "linux" as const,
label: "custom-gateway.service", label: "custom-gateway.service",
detail: `unit: ${unitPath}`, detail: `unit: ${unitPath}`,
sourcePath: unitPath,
scope, scope,
legacy: false, legacy: false,
}; };
@ -183,6 +185,7 @@ describe("maybeScanExtraGatewayServices", () => {
platform: "darwin" as const, platform: "darwin" as const,
label: "com.example.openclaw-gateway", label: "com.example.openclaw-gateway",
detail: "plist: /Users/test/Library/LaunchAgents/com.example.openclaw-gateway.plist", 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, scope: "user" as const,
legacy: false, legacy: false,
}; };
@ -259,6 +262,7 @@ describe("maybeScanExtraGatewayServices", () => {
platform: "linux", platform: "linux",
label: "clawdbot-gateway.service", label: "clawdbot-gateway.service",
detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service", detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service",
sourcePath: "/home/test/.config/systemd/user/clawdbot-gateway.service",
scope: "user", scope: "user",
legacy: true, legacy: true,
}, },
@ -299,6 +303,7 @@ describe("maybeScanExtraGatewayServices", () => {
platform: "linux", platform: "linux",
label, label,
detail: `unit: /home/test/.config/systemd/user/${label}`, detail: `unit: /home/test/.config/systemd/user/${label}`,
sourcePath: `/home/test/.config/systemd/user/${label}`,
scope: "user", scope: "user",
marker: "clawdbot", marker: "clawdbot",
legacy: true, legacy: true,
@ -514,6 +519,7 @@ describe("maybeScanExtraGatewayServices", () => {
platform: "linux", platform: "linux",
label: "clawdbot-gateway.service", label: "clawdbot-gateway.service",
detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service", detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service",
sourcePath: "/home/test/.config/systemd/user/clawdbot-gateway.service",
scope: "user", scope: "user",
legacy: true, legacy: true,
}, },

View file

@ -116,14 +116,6 @@ async function confirmLegacyLaunchdServiceUnloaded(serviceTarget: string): Promi
return false; 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( async function filterInactiveExtraGatewayServices(
services: ExtraGatewayService[], services: ExtraGatewayService[],
): Promise<ExtraGatewayService[]> { ): Promise<ExtraGatewayService[]> {
@ -205,7 +197,7 @@ async function cleanupLegacyDarwinServices(
const failed: string[] = []; const failed: string[] = [];
for (const svc of services) { for (const svc of services) {
const plistPath = extractDetailPath(svc.detail, "plist:"); const plistPath = svc.sourcePath;
if (!plistPath) { if (!plistPath) {
failed.push(`${svc.label} (missing plist path)`); failed.push(`${svc.label} (missing plist path)`);
continue; continue;

View file

@ -13,6 +13,7 @@ export type ExtraGatewayService = {
platform: "darwin" | "linux" | "win32"; platform: "darwin" | "linux" | "win32";
label: string; label: string;
detail: string; detail: string;
sourcePath?: string;
scope: "user" | "system"; scope: "user" | "system";
marker?: "openclaw" | "clawdbot"; marker?: "openclaw" | "clawdbot";
legacy?: boolean; legacy?: boolean;
@ -116,6 +117,7 @@ export async function scanSystemdDir(params: {
platform: "linux", platform: "linux",
label: entry, label: entry,
detail: `unit: ${fullPath}`, detail: `unit: ${fullPath}`,
sourcePath: fullPath,
scope: params.scope, scope: params.scope,
marker, marker,
legacy: marker !== "openclaw", legacy: marker !== "openclaw",

View file

@ -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"]);
});
});

View file

@ -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", () => { describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem", () => {
// These tests write real .service files to a temp dir and call findExtraGatewayServices // 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. // with that dir as HOME. No platform mocking or fs mocking needed.
@ -299,6 +174,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem"
platform: "linux", platform: "linux",
label: "clawdbot-gateway.service", label: "clawdbot-gateway.service",
detail: `unit: ${unitPath}`, detail: `unit: ${unitPath}`,
sourcePath: unitPath,
scope: "user", scope: "user",
marker: "clawdbot", marker: "clawdbot",
legacy: true, legacy: true,
@ -343,6 +219,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem"
platform: "linux", platform: "linux",
label: "clawdbot-gateway.service", label: "clawdbot-gateway.service",
detail: `unit: ${unitPath}`, detail: `unit: ${unitPath}`,
sourcePath: unitPath,
scope: "user", scope: "user",
marker: "clawdbot", marker: "clawdbot",
legacy: true, legacy: true,
@ -382,6 +259,7 @@ describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem"
platform: "linux", platform: "linux",
label: "custom-openclaw.service", label: "custom-openclaw.service",
detail: `unit: ${unitPath}`, detail: `unit: ${unitPath}`,
sourcePath: unitPath,
scope: "user", scope: "user",
marker: "openclaw", marker: "openclaw",
legacy: false, legacy: false,
@ -507,6 +385,7 @@ describe("findExtraGatewayServices (darwin / scanLaunchdDir) — real filesystem
platform: "darwin", platform: "darwin",
label: "com.example.openclaw-gateway", label: "com.example.openclaw-gateway",
detail: `plist: ${plistPath}`, detail: `plist: ${plistPath}`,
sourcePath: plistPath,
scope: "user", scope: "user",
marker: "openclaw", marker: "openclaw",
legacy: false, legacy: false,
@ -722,6 +601,7 @@ describe("managed Gateway inventory projections", () => {
label: "openclaw@.service", label: "openclaw@.service",
scope: "system", scope: "system",
detail: `unit: ${path.join("/etc/systemd/system", "openclaw@.service")}`, detail: `unit: ${path.join("/etc/systemd/system", "openclaw@.service")}`,
sourcePath: path.join("/etc/systemd/system", "openclaw@.service"),
marker: "openclaw", marker: "openclaw",
legacy: false, 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" }); Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
const home = tempDirs.make("managed-systemd-load-paths-", os.tmpdir()); const home = tempDirs.make("managed-systemd-load-paths-", os.tmpdir());
const write = isolateNativeRoots(home); const write = isolateNativeRoots(home);
@ -814,6 +694,25 @@ describe("managed Gateway inventory projections", () => {
"system-generated-gateway.service", "system-generated-gateway.service",
"transient-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([ it.each([
@ -864,6 +763,7 @@ describe("managed Gateway inventory projections", () => {
platform: "darwin", platform: "darwin",
label, label,
detail: `plist: ${file}`, detail: `plist: ${file}`,
sourcePath: file,
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
legacy: false, legacy: false,

View file

@ -89,9 +89,7 @@ export function renderGatewayServiceCleanupHints(
for (const service of services) { for (const service of services) {
switch (service.platform) { switch (service.platform) {
case "darwin": { case "darwin": {
const plistPath = service.detail.startsWith("plist:") const plistPath = service.sourcePath;
? service.detail.slice("plist:".length).trim()
: undefined;
// Global LaunchAgents still run in a GUI domain; only LaunchDaemons // Global LaunchAgents still run in a GUI domain; only LaunchDaemons
// belong to the system domain regardless of their shared file scope. // belong to the system domain regardless of their shared file scope.
const domain = const domain =
@ -184,6 +182,7 @@ async function scanLaunchdDir(params: {
platform: "darwin", platform: "darwin",
label, label,
detail: `plist: ${fullPath}`, detail: `plist: ${fullPath}`,
sourcePath: fullPath,
scope: params.scope, scope: params.scope,
marker, marker,
legacy: marker !== "openclaw" || isLegacyLabel(label), legacy: marker !== "openclaw" || isLegacyLabel(label),
@ -506,6 +505,7 @@ async function scanGatewayServices(
platform: "linux", platform: "linux",
label: unit.name, label: unit.name,
detail: `unit: ${unit.fragmentPath}`, detail: `unit: ${unit.fragmentPath}`,
sourcePath: unit.fragmentPath,
scope, scope,
marker: marker ?? "openclaw", marker: marker ?? "openclaw",
legacy: marker === "clawdbot", legacy: marker === "clawdbot",

View file

@ -183,6 +183,7 @@ it.each([
platform: "darwin", platform: "darwin",
label, label,
detail: `plist: ${locations.local.plist}`, detail: `plist: ${locations.local.plist}`,
sourcePath: locations.local.plist,
scope: "user", scope: "user",
marker: "openclaw", marker: "openclaw",
}, },
@ -190,6 +191,7 @@ it.each([
platform: "darwin", platform: "darwin",
label, label,
detail: `plist: ${locations.global.plist}`, detail: `plist: ${locations.global.plist}`,
sourcePath: locations.global.plist,
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -197,6 +199,7 @@ it.each([
platform: "darwin", platform: "darwin",
label, label,
detail: `plist: ${systemPlist}`, detail: `plist: ${systemPlist}`,
sourcePath: systemPlist,
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -353,7 +356,13 @@ it.each([
if (scenario.loaded === "system") { if (scenario.loaded === "system") {
expect( expect(
inventory.renderGatewayServiceCleanupHints([ 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}`); ).toContain(`sudo launchctl bootout system/${label}`);
} }
@ -380,6 +389,7 @@ it.each([false, true])(
scope: "user" as const, scope: "user" as const,
label: "custom-sibling.service", label: "custom-sibling.service",
detail: "unit: /synthetic/custom-sibling.service", detail: "unit: /synthetic/custom-sibling.service",
sourcePath: "/synthetic/custom-sibling.service",
}; };
vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue({ vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue({
services: [sibling, sibling], services: [sibling, sibling],

View file

@ -98,18 +98,11 @@ function profileEnvFields(profile: string): GatewayServiceEnv {
return profile === "default" ? {} : { OPENCLAW_PROFILE: profile }; 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( function bindingFromSystemdService(
svc: ExtraGatewayService, svc: ExtraGatewayService,
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): ManagedGatewayBinding { ): ManagedGatewayBinding {
const unitPath = detailPath("unit:", svc.detail); const unitPath = svc.sourcePath;
const unitName = resolveSystemdTemplateInstanceName(svc.label, { const unitName = resolveSystemdTemplateInstanceName(svc.label, {
OPENCLAW_SYSTEMD_UNIT: svc.label, OPENCLAW_SYSTEMD_UNIT: svc.label,
}); });
@ -125,7 +118,7 @@ function bindingFromLaunchdService(
svc: ExtraGatewayService, svc: ExtraGatewayService,
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): ManagedGatewayBinding { ): ManagedGatewayBinding {
const plistPath = detailPath("plist:", svc.detail); const plistPath = svc.sourcePath;
return { return {
scope: svc.scope, scope: svc.scope,
...(plistPath ? { launchAgentPlistPath: plistPath } : {}), ...(plistPath ? { launchAgentPlistPath: plistPath } : {}),

View file

@ -156,6 +156,7 @@ it.each<ScopeCase>([
marker: "openclaw" as const, marker: "openclaw" as const,
label: otherName, label: otherName,
detail: `unit: ${otherPath}`, detail: `unit: ${otherPath}`,
sourcePath: otherPath,
}, },
] ]
: []), : []),
@ -165,6 +166,7 @@ it.each<ScopeCase>([
marker: "openclaw", marker: "openclaw",
label: file, label: file,
detail: `unit: ${target.unitPath}`, detail: `unit: ${target.unitPath}`,
sourcePath: target.unitPath,
}, },
]); ]);
vi.spyOn(fs, "access").mockRejectedValue( vi.spyOn(fs, "access").mockRejectedValue(
@ -511,6 +513,7 @@ it("reads the system template instance while a separate user Gateway is installe
marker: "openclaw", marker: "openclaw",
label: "openclaw@.service", label: "openclaw@.service",
detail: `unit: ${templateUnit}`, detail: `unit: ${templateUnit}`,
sourcePath: templateUnit,
}, },
]); ]);
mockProcessPlatform("linux"); mockProcessPlatform("linux");
@ -651,6 +654,7 @@ it("findSystemdGatewayInstallation expands a system template to this account's i
platform: "linux", platform: "linux",
label: "openclaw@.service", label: "openclaw@.service",
detail: "unit: /etc/systemd/system/openclaw@.service", detail: "unit: /etc/systemd/system/openclaw@.service",
sourcePath: "/etc/systemd/system/openclaw@.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },

View file

@ -4,18 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findInstalledSystemdGatewayScope, isNonFatalSystemdInstallProbeError } from "./systemd.js"; import { findInstalledSystemdGatewayScope, isNonFatalSystemdInstallProbeError } from "./systemd.js";
const findSystemGatewayServicesMock = vi.hoisted(() => const findSystemGatewayServicesMock = vi.hoisted(() =>
vi.fn< vi.fn<typeof import("./inspect.js").findSystemGatewayServices>(async () => []),
() => Promise<
Array<{
platform: "linux";
label: string;
detail: string;
scope: "user" | "system";
marker?: "openclaw" | "clawdbot";
legacy?: boolean;
}>
>
>(async () => []),
); );
vi.mock("./inspect.js", () => ({ vi.mock("./inspect.js", () => ({
@ -81,6 +70,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw.service", label: "openclaw.service",
detail: "unit: /etc/systemd/system/openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service",
sourcePath: "/etc/systemd/system/openclaw.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -100,6 +90,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw-darlene.service", label: "openclaw-darlene.service",
detail: "unit: /etc/systemd/system/openclaw-darlene.service", detail: "unit: /etc/systemd/system/openclaw-darlene.service",
sourcePath: "/etc/systemd/system/openclaw-darlene.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -118,6 +109,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw-gateway-lisa.service", label: "openclaw-gateway-lisa.service",
detail: "unit: /etc/systemd/system/openclaw-gateway-lisa.service", detail: "unit: /etc/systemd/system/openclaw-gateway-lisa.service",
sourcePath: "/etc/systemd/system/openclaw-gateway-lisa.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -136,6 +128,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "my-custom-gateway.service", label: "my-custom-gateway.service",
detail: "unit: /etc/systemd/system/my-custom-gateway.service", detail: "unit: /etc/systemd/system/my-custom-gateway.service",
sourcePath: "/etc/systemd/system/my-custom-gateway.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -216,6 +209,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label, label,
detail: `unit: /etc/systemd/system/${label}`, detail: `unit: /etc/systemd/system/${label}`,
sourcePath: `/etc/systemd/system/${label}`,
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -243,6 +237,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw@.service", label: "openclaw@.service",
detail: "unit: /etc/systemd/system/openclaw@.service", detail: "unit: /etc/systemd/system/openclaw@.service",
sourcePath: "/etc/systemd/system/openclaw@.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -265,6 +260,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw@other.service", label: "openclaw@other.service",
detail: "unit: /etc/systemd/system/openclaw@other.service", detail: "unit: /etc/systemd/system/openclaw@other.service",
sourcePath: "/etc/systemd/system/openclaw@other.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -303,6 +299,7 @@ describe("systemd gateway identity (openclaw#119648)", () => {
platform: "linux", platform: "linux",
label: "openclaw-darlene.service", label: "openclaw-darlene.service",
detail: "unit: /etc/systemd/system/openclaw-darlene.service", detail: "unit: /etc/systemd/system/openclaw-darlene.service",
sourcePath: "/etc/systemd/system/openclaw-darlene.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },

View file

@ -460,8 +460,7 @@ async function findMarkerOwnedSystemSystemdUnit(
) { ) {
continue; continue;
} }
const match = /^unit:\s*(.+)$/.exec(svc.detail.trim()); const unitPath = svc.sourcePath;
const unitPath = match?.[1]?.trim();
if (unitPath) { if (unitPath) {
const target: SystemdServiceReadTarget = { const target: SystemdServiceReadTarget = {
scope: "system", scope: "system",

View file

@ -27,18 +27,7 @@ const assertNoSystemSystemdOwnershipMock = vi.hoisted(() =>
vi.fn<(unitName: string, timeoutMs?: number) => Promise<void>>(async () => {}), vi.fn<(unitName: string, timeoutMs?: number) => Promise<void>>(async () => {}),
); );
const findSystemGatewayServicesMock = vi.hoisted(() => const findSystemGatewayServicesMock = vi.hoisted(() =>
vi.fn< vi.fn<typeof import("./inspect.js").findSystemGatewayServices>(async () => []),
() => Promise<
Array<{
platform: "linux";
label: string;
detail: string;
scope: "user" | "system";
marker?: "openclaw" | "clawdbot";
legacy?: boolean;
}>
>
>(async () => []),
); );
vi.mock("./inspect.js", () => ({ vi.mock("./inspect.js", () => ({
@ -596,6 +585,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => {
platform: "linux", platform: "linux",
label: "openclaw-rescue.service", label: "openclaw-rescue.service",
detail: "unit: /etc/systemd/system/openclaw-rescue.service", detail: "unit: /etc/systemd/system/openclaw-rescue.service",
sourcePath: "/etc/systemd/system/openclaw-rescue.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -640,6 +630,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => {
platform: "linux", platform: "linux",
label: "openclaw.service", label: "openclaw.service",
detail: "unit: /etc/systemd/system/openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service",
sourcePath: "/etc/systemd/system/openclaw.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -660,6 +651,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => {
platform: "linux", platform: "linux",
label: "openclaw.service", label: "openclaw.service",
detail: "unit: /etc/systemd/system/openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service",
sourcePath: "/etc/systemd/system/openclaw.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },
@ -678,6 +670,7 @@ describe("system-scope gateway unit detection (openclaw#87577)", () => {
platform: "linux", platform: "linux",
label: "openclaw.service", label: "openclaw.service",
detail: "unit: /etc/systemd/system/openclaw.service", detail: "unit: /etc/systemd/system/openclaw.service",
sourcePath: "/etc/systemd/system/openclaw.service",
scope: "system", scope: "system",
marker: "openclaw", marker: "openclaw",
}, },

View file

@ -29,7 +29,9 @@ it.each([
const label = "org.example.fence-sibling"; const label = "org.example.fence-sibling";
const plist = path.join(home, "Library", "LaunchAgents", `${label}.plist`); const plist = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
vi.spyOn(inventory, "listManagedOpenClawGatewayServices").mockResolvedValue({ 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: [], errors: [],
}); });
const native = vi.spyOn(launchdExec, "execLaunchctl").mockImplementation(async (args) => { const native = vi.spyOn(launchdExec, "execLaunchctl").mockImplementation(async (args) => {