Skip to content

Commit b5e81e1

Browse files
yetvalsteipete
authored andcommitted
fix(daemon): detect system-scope systemd gateway units on Linux (#87577)
Teach the Linux gateway daemon to recognize system-scope systemd units in addition to user-scope: the canonical user path, the canonical system path, and any non-canonically named system unit carrying the OpenClaw gateway marker (the reporter's /etc/systemd/system/openclaw.service shape). status, is-enabled, restart, and stop now route through the detected scope and unit name, querying the system manager (no --user) for system units. For a detected system-scope unit, root callers run systemctl <action> directly via the canonical service action; non-root callers fail closed with sudo systemctl guidance naming the real unit instead of signaling a supervisor-owned process. The unmanaged lifecycle fallback now delegates system-scope units to that same canonical path (root -> systemctl, non-root -> sudo guidance) rather than throwing unconditionally, so both code paths share one policy and one hint string and a root operator is never told to sudo a command it can already run. Adds regression coverage for detection, routing, and both root/non-root operator paths in the lifecycle fallback.
1 parent db40fde commit b5e81e1

5 files changed

Lines changed: 508 additions & 32 deletions

File tree

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(()
5454
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
5555
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
5656
const repairLoadedGatewayServiceForStart = vi.hoisted(() => vi.fn());
57+
const findInstalledSystemdGatewayScope = vi.hoisted(() =>
58+
vi.fn<() => Promise<{ scope: "user" | "system"; unitName: string; unitPath: string } | null>>(
59+
async () => null,
60+
),
61+
);
62+
const restartSystemdService = vi.hoisted(() =>
63+
vi.fn<() => Promise<{ outcome: "completed" }>>(async () => ({ outcome: "completed" })),
64+
);
65+
const stopSystemdService = vi.hoisted(() => vi.fn<() => Promise<void>>(async () => {}));
5766

5867
function requireMockCallArg(
5968
mockFn: { mock: { calls: unknown[][] } },
@@ -113,6 +122,12 @@ vi.mock("../../daemon/service.js", () => ({
113122
resolveGatewayService: () => service,
114123
}));
115124

125+
vi.mock("../../daemon/systemd.js", () => ({
126+
findInstalledSystemdGatewayScope: () => findInstalledSystemdGatewayScope(),
127+
restartSystemdService: () => restartSystemdService(),
128+
stopSystemdService: () => stopSystemdService(),
129+
}));
130+
116131
vi.mock("./launchd-recovery.js", () => ({
117132
recoverInstalledLaunchAgent: (args: { result: "started" | "restarted" }) =>
118133
recoverInstalledLaunchAgent(args),
@@ -208,6 +223,12 @@ describe("runDaemonRestart health checks", () => {
208223
service.restart.mockResolvedValue({ outcome: "completed" });
209224
runServiceStart.mockResolvedValue(undefined);
210225
recoverInstalledLaunchAgent.mockResolvedValue(null);
226+
findInstalledSystemdGatewayScope.mockReset();
227+
findInstalledSystemdGatewayScope.mockResolvedValue(null);
228+
restartSystemdService.mockReset();
229+
restartSystemdService.mockResolvedValue({ outcome: "completed" });
230+
stopSystemdService.mockReset();
231+
stopSystemdService.mockResolvedValue(undefined);
211232

212233
runServiceRestart.mockImplementation(async (params: RestartParams) => {
213234
const fail = (message: string, hints?: string[]) => {
@@ -607,6 +628,89 @@ describe("runDaemonRestart health checks", () => {
607628
);
608629
});
609630

631+
it("delegates system-scope restart to systemctl without unmanaged signaling when root (openclaw#87577)", async () => {
632+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
633+
findInstalledSystemdGatewayScope.mockResolvedValue({
634+
scope: "system",
635+
unitName: "openclaw.service",
636+
unitPath: "/etc/systemd/system/openclaw.service",
637+
});
638+
restartSystemdService.mockResolvedValue({ outcome: "completed" });
639+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
640+
mockUnmanagedRestart();
641+
642+
await expect(runDaemonRestart({ json: true })).resolves.toBe(true);
643+
644+
expect(restartSystemdService).toHaveBeenCalled();
645+
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
646+
expect(probeGateway).not.toHaveBeenCalled();
647+
});
648+
649+
it("surfaces systemd sudo guidance and never signals when restarting a system-scope unit as non-root (openclaw#87577)", async () => {
650+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
651+
findInstalledSystemdGatewayScope.mockResolvedValue({
652+
scope: "system",
653+
unitName: "openclaw.service",
654+
unitPath: "/etc/systemd/system/openclaw.service",
655+
});
656+
restartSystemdService.mockRejectedValue(
657+
new Error(
658+
"openclaw.service is a system-scope unit (/etc/systemd/system/openclaw.service); run `sudo systemctl restart openclaw.service` to restart it",
659+
),
660+
);
661+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
662+
mockUnmanagedRestart();
663+
664+
await expect(runDaemonRestart({ json: true })).rejects.toThrow(
665+
/sudo systemctl restart openclaw\.service/,
666+
);
667+
668+
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
669+
expect(probeGateway).not.toHaveBeenCalled();
670+
});
671+
672+
it("delegates system-scope stop to systemctl without unmanaged signaling when root (openclaw#87577)", async () => {
673+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
674+
findInstalledSystemdGatewayScope.mockResolvedValue({
675+
scope: "system",
676+
unitName: "openclaw-gateway.service",
677+
unitPath: "/etc/systemd/system/openclaw-gateway.service",
678+
});
679+
stopSystemdService.mockResolvedValue(undefined);
680+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
681+
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
682+
await params.onNotLoaded?.();
683+
});
684+
685+
await expect(runDaemonStop({ json: true })).resolves.toBeUndefined();
686+
expect(stopSystemdService).toHaveBeenCalled();
687+
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
688+
});
689+
690+
it("surfaces systemd sudo guidance and never signals when stopping a system-scope unit as non-root (openclaw#87577)", async () => {
691+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
692+
findInstalledSystemdGatewayScope.mockResolvedValue({
693+
scope: "system",
694+
unitName: "openclaw-gateway.service",
695+
unitPath: "/etc/systemd/system/openclaw-gateway.service",
696+
});
697+
stopSystemdService.mockRejectedValue(
698+
new Error(
699+
"openclaw-gateway.service is a system-scope unit (/etc/systemd/system/openclaw-gateway.service); run `sudo systemctl stop openclaw-gateway.service` to stop it",
700+
),
701+
);
702+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
703+
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
704+
await params.onNotLoaded?.();
705+
});
706+
707+
await expect(runDaemonStop({ json: true })).rejects.toThrow(
708+
/sudo systemctl stop openclaw-gateway\.service/,
709+
);
710+
expect(stopSystemdService).toHaveBeenCalled();
711+
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
712+
});
713+
610714
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
611715
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
612716
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {

src/cli/daemon-cli/lifecycle.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
33
import { isRestartEnabled } from "../../config/commands.flags.js";
44
import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js";
55
import { resolveGatewayService } from "../../daemon/service.js";
6+
import {
7+
findInstalledSystemdGatewayScope,
8+
restartSystemdService,
9+
stopSystemdService,
10+
} from "../../daemon/systemd.js";
611
import { callGatewayCli } from "../../gateway/call.js";
712
import { probeGateway } from "../../gateway/probe.js";
813
import {
@@ -16,6 +21,7 @@ import { defaultRuntime } from "../../runtime.js";
1621
import { formatCliCommand } from "../command-format.js";
1722
import { parseDurationMs } from "../parse-duration.js";
1823
import { recoverInstalledLaunchAgent } from "./launchd-recovery.js";
24+
import { createNullWriter } from "./response.js";
1925
import {
2026
runServiceRestart,
2127
runServiceStart,
@@ -112,7 +118,36 @@ function resolveVerifiedGatewayListenerPids(port: number): number[] {
112118
);
113119
}
114120

121+
async function handleSystemScopeSystemdGateway(
122+
action: "stop" | "restart",
123+
): Promise<{ result: "stopped" | "restarted"; message: string } | null> {
124+
if (process.platform !== "linux") {
125+
return null;
126+
}
127+
const installed = await findInstalledSystemdGatewayScope(process.env).catch(() => null);
128+
if (installed?.scope !== "system") {
129+
return null;
130+
}
131+
const stdout = createNullWriter();
132+
if (action === "stop") {
133+
await stopSystemdService({ stdout, env: process.env });
134+
return {
135+
result: "stopped",
136+
message: `Gateway stopped via system-scope systemd unit ${installed.unitName}.`,
137+
};
138+
}
139+
await restartSystemdService({ stdout, env: process.env });
140+
return {
141+
result: "restarted",
142+
message: `Gateway restarted via system-scope systemd unit ${installed.unitName}.`,
143+
};
144+
}
145+
115146
async function stopGatewayWithoutServiceManager(port: number) {
147+
const managed = await handleSystemScopeSystemdGateway("stop");
148+
if (managed) {
149+
return managed;
150+
}
116151
const pids = resolveVerifiedGatewayListenerPids(port);
117152
if (pids.length === 0) {
118153
return null;
@@ -196,6 +231,10 @@ async function restartGatewayWithoutServiceManager(
196231
port: number,
197232
restartIntent?: GatewayRestartIntent,
198233
) {
234+
const managed = await handleSystemScopeSystemdGateway("restart");
235+
if (managed) {
236+
return managed;
237+
}
199238
await assertUnmanagedGatewayRestartEnabled(port);
200239
const pids = resolveVerifiedGatewayListenerPids(port);
201240
if (pids.length === 0) {

src/cli/daemon-cli/response.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function buildDaemonServiceSnapshot(service: GatewayService, loaded: bool
9090
};
9191
}
9292

93-
function createNullWriter(): Writable {
93+
export function createNullWriter(): Writable {
9494
return new Writable({
9595
write(_chunk, _encoding, callback) {
9696
callback();

0 commit comments

Comments
 (0)