mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(daemon): detect system-scope systemd gateway units on Linux
Detect OpenClaw gateway units installed in the system systemd scope, including marker-owned custom unit names such as `openclaw.service`. Route status/restart/stop through the system manager when appropriate, and show non-root users the matching `sudo systemctl ...` command instead of falling back to unmanaged process signaling. Fixes #87577. Thanks @yetval. Verification: - `node scripts/run-vitest.mjs src/daemon/systemd.test.ts src/cli/daemon-cli/lifecycle.test.ts src/daemon/inspect.test.ts src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/status.gather.test.ts src/cli/daemon-cli/response.test.ts src/commands/doctor-gateway-daemon-flow.test.ts src/cli/update-cli/restart-helper.test.ts src/infra/outbound/message-action-runner.core-send.test.ts` - AWS Crabbox `cbx_69f97dff5e5c`, run `run_a68431b3dad6`: exact SHA checkout, focused tests, real `/etc/systemd/system/openclaw.service` status/restart/stop proof.
This commit is contained in:
@@ -54,6 +54,15 @@ const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(()
|
||||
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
|
||||
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
|
||||
const repairLoadedGatewayServiceForStart = vi.hoisted(() => vi.fn());
|
||||
const findInstalledSystemdGatewayScope = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<{ scope: "user" | "system"; unitName: string; unitPath: string } | null>>(
|
||||
async () => null,
|
||||
),
|
||||
);
|
||||
const restartSystemdService = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<{ outcome: "completed" }>>(async () => ({ outcome: "completed" })),
|
||||
);
|
||||
const stopSystemdService = vi.hoisted(() => vi.fn<() => Promise<void>>(async () => {}));
|
||||
|
||||
function requireMockCallArg(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
@@ -113,6 +122,12 @@ vi.mock("../../daemon/service.js", () => ({
|
||||
resolveGatewayService: () => service,
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/systemd.js", () => ({
|
||||
findInstalledSystemdGatewayScope: () => findInstalledSystemdGatewayScope(),
|
||||
restartSystemdService: () => restartSystemdService(),
|
||||
stopSystemdService: () => stopSystemdService(),
|
||||
}));
|
||||
|
||||
vi.mock("./launchd-recovery.js", () => ({
|
||||
recoverInstalledLaunchAgent: (args: { result: "started" | "restarted" }) =>
|
||||
recoverInstalledLaunchAgent(args),
|
||||
@@ -208,6 +223,12 @@ describe("runDaemonRestart health checks", () => {
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
runServiceStart.mockResolvedValue(undefined);
|
||||
recoverInstalledLaunchAgent.mockResolvedValue(null);
|
||||
findInstalledSystemdGatewayScope.mockReset();
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue(null);
|
||||
restartSystemdService.mockReset();
|
||||
restartSystemdService.mockResolvedValue({ outcome: "completed" });
|
||||
stopSystemdService.mockReset();
|
||||
stopSystemdService.mockResolvedValue(undefined);
|
||||
|
||||
runServiceRestart.mockImplementation(async (params: RestartParams) => {
|
||||
const fail = (message: string, hints?: string[]) => {
|
||||
@@ -607,6 +628,89 @@ describe("runDaemonRestart health checks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates system-scope restart to systemctl without unmanaged signaling when root (openclaw#87577)", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue({
|
||||
scope: "system",
|
||||
unitName: "openclaw.service",
|
||||
unitPath: "/etc/systemd/system/openclaw.service",
|
||||
});
|
||||
restartSystemdService.mockResolvedValue({ outcome: "completed" });
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockUnmanagedRestart();
|
||||
|
||||
await expect(runDaemonRestart({ json: true })).resolves.toBe(true);
|
||||
|
||||
expect(restartSystemdService).toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
expect(probeGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces systemd sudo guidance and never signals when restarting a system-scope unit as non-root (openclaw#87577)", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue({
|
||||
scope: "system",
|
||||
unitName: "openclaw.service",
|
||||
unitPath: "/etc/systemd/system/openclaw.service",
|
||||
});
|
||||
restartSystemdService.mockRejectedValue(
|
||||
new Error(
|
||||
"openclaw.service is a system-scope unit (/etc/systemd/system/openclaw.service); run `sudo systemctl restart openclaw.service` to restart it",
|
||||
),
|
||||
);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockUnmanagedRestart();
|
||||
|
||||
await expect(runDaemonRestart({ json: true })).rejects.toThrow(
|
||||
/sudo systemctl restart openclaw\.service/,
|
||||
);
|
||||
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
expect(probeGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates system-scope stop to systemctl without unmanaged signaling when root (openclaw#87577)", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue({
|
||||
scope: "system",
|
||||
unitName: "openclaw-gateway.service",
|
||||
unitPath: "/etc/systemd/system/openclaw-gateway.service",
|
||||
});
|
||||
stopSystemdService.mockResolvedValue(undefined);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
|
||||
await expect(runDaemonStop({ json: true })).resolves.toBeUndefined();
|
||||
expect(stopSystemdService).toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces systemd sudo guidance and never signals when stopping a system-scope unit as non-root (openclaw#87577)", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue({
|
||||
scope: "system",
|
||||
unitName: "openclaw-gateway.service",
|
||||
unitPath: "/etc/systemd/system/openclaw-gateway.service",
|
||||
});
|
||||
stopSystemdService.mockRejectedValue(
|
||||
new Error(
|
||||
"openclaw-gateway.service is a system-scope unit (/etc/systemd/system/openclaw-gateway.service); run `sudo systemctl stop openclaw-gateway.service` to stop it",
|
||||
),
|
||||
);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
|
||||
await expect(runDaemonStop({ json: true })).rejects.toThrow(
|
||||
/sudo systemctl stop openclaw-gateway\.service/,
|
||||
);
|
||||
expect(stopSystemdService).toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { isRestartEnabled } from "../../config/commands.flags.js";
|
||||
import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import {
|
||||
findInstalledSystemdGatewayScope,
|
||||
restartSystemdService,
|
||||
stopSystemdService,
|
||||
} from "../../daemon/systemd.js";
|
||||
import { callGatewayCli } from "../../gateway/call.js";
|
||||
import { probeGateway } from "../../gateway/probe.js";
|
||||
import {
|
||||
@@ -16,6 +21,7 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { recoverInstalledLaunchAgent } from "./launchd-recovery.js";
|
||||
import { createNullWriter } from "./response.js";
|
||||
import {
|
||||
runServiceRestart,
|
||||
runServiceStart,
|
||||
@@ -112,7 +118,36 @@ function resolveVerifiedGatewayListenerPids(port: number): number[] {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSystemScopeSystemdGateway(
|
||||
action: "stop" | "restart",
|
||||
): Promise<{ result: "stopped" | "restarted"; message: string } | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
const installed = await findInstalledSystemdGatewayScope(process.env).catch(() => null);
|
||||
if (installed?.scope !== "system") {
|
||||
return null;
|
||||
}
|
||||
const stdout = createNullWriter();
|
||||
if (action === "stop") {
|
||||
await stopSystemdService({ stdout, env: process.env });
|
||||
return {
|
||||
result: "stopped",
|
||||
message: `Gateway stopped via system-scope systemd unit ${installed.unitName}.`,
|
||||
};
|
||||
}
|
||||
await restartSystemdService({ stdout, env: process.env });
|
||||
return {
|
||||
result: "restarted",
|
||||
message: `Gateway restarted via system-scope systemd unit ${installed.unitName}.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopGatewayWithoutServiceManager(port: number) {
|
||||
const managed = await handleSystemScopeSystemdGateway("stop");
|
||||
if (managed) {
|
||||
return managed;
|
||||
}
|
||||
const pids = resolveVerifiedGatewayListenerPids(port);
|
||||
if (pids.length === 0) {
|
||||
return null;
|
||||
@@ -196,6 +231,10 @@ async function restartGatewayWithoutServiceManager(
|
||||
port: number,
|
||||
restartIntent?: GatewayRestartIntent,
|
||||
) {
|
||||
const managed = await handleSystemScopeSystemdGateway("restart");
|
||||
if (managed) {
|
||||
return managed;
|
||||
}
|
||||
await assertUnmanagedGatewayRestartEnabled(port);
|
||||
const pids = resolveVerifiedGatewayListenerPids(port);
|
||||
if (pids.length === 0) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function buildDaemonServiceSnapshot(service: GatewayService, loaded: bool
|
||||
};
|
||||
}
|
||||
|
||||
function createNullWriter(): Writable {
|
||||
export function createNullWriter(): Writable {
|
||||
return new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { ExecFileException, ExecFileOptionsWithStringEncoding } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ExecFileException, ExecFileOptionsWithStringEncoding } from "node:child_process";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ExecFileCallback = (
|
||||
error: ExecFileException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
type ExecFileCallback = (error: ExecFileException | null, stdout: string, stderr: string) => void;
|
||||
type ExecFileMock = (
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -18,6 +14,24 @@ type ExecFileMock = (
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn<ExecFileMock>());
|
||||
const existsSyncMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const findSystemGatewayServicesMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<
|
||||
Array<{
|
||||
platform: "linux";
|
||||
label: string;
|
||||
detail: string;
|
||||
scope: "user" | "system";
|
||||
marker?: "openclaw" | "clawdbot";
|
||||
legacy?: boolean;
|
||||
}>
|
||||
>
|
||||
>(async () => []),
|
||||
);
|
||||
|
||||
vi.mock("./inspect.js", () => ({
|
||||
findSystemGatewayServices: () => findSystemGatewayServicesMock(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs")>()),
|
||||
@@ -58,6 +72,7 @@ vi.mock("./exec-file.js", () => {
|
||||
import { splitArgsPreservingQuotes } from "./arg-split.js";
|
||||
import { parseSystemdEnvAssignments, parseSystemdExecStart } from "./systemd-unit.js";
|
||||
import {
|
||||
findInstalledSystemdGatewayScope,
|
||||
installSystemdService,
|
||||
isNonFatalSystemdInstallProbeError,
|
||||
isSystemdServiceEnabled,
|
||||
@@ -512,6 +527,219 @@ describe("isSystemdUnitActive", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-scope gateway unit detection (openclaw#87577)", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
execFileMock.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockUnitFileLayout(layout: { user?: boolean; system?: string | false }) {
|
||||
vi.spyOn(fs, "access").mockImplementation(async (pathArg) => {
|
||||
const p = pathLikeToString(pathArg);
|
||||
if (layout.user && p.includes("/.config/systemd/user/")) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof layout.system === "string" && p === layout.system) {
|
||||
return undefined;
|
||||
}
|
||||
const err = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
it("findInstalledSystemdGatewayScope prefers user scope when both exist", async () => {
|
||||
mockUnitFileLayout({
|
||||
user: true,
|
||||
system: "/etc/systemd/system/openclaw-gateway.service",
|
||||
});
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result?.scope).toBe("user");
|
||||
expect(result?.unitName).toBe(GATEWAY_SERVICE);
|
||||
expect(result?.unitPath).toContain("/.config/systemd/user/openclaw-gateway.service");
|
||||
});
|
||||
|
||||
it("findInstalledSystemdGatewayScope detects system-scope unit in /etc/systemd/system", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result).toEqual({
|
||||
scope: "system",
|
||||
unitName: GATEWAY_SERVICE,
|
||||
unitPath: "/etc/systemd/system/openclaw-gateway.service",
|
||||
});
|
||||
});
|
||||
|
||||
it("findInstalledSystemdGatewayScope falls back to /usr/lib/systemd/system", async () => {
|
||||
mockUnitFileLayout({ system: "/usr/lib/systemd/system/openclaw-gateway.service" });
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result?.scope).toBe("system");
|
||||
expect(result?.unitPath).toBe("/usr/lib/systemd/system/openclaw-gateway.service");
|
||||
});
|
||||
|
||||
it("findInstalledSystemdGatewayScope returns null when no unit file exists", async () => {
|
||||
mockUnitFileLayout({ system: false });
|
||||
findSystemGatewayServicesMock.mockResolvedValueOnce([]);
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("findInstalledSystemdGatewayScope falls back to marker-owned system unit with custom name", async () => {
|
||||
mockUnitFileLayout({ system: false });
|
||||
findSystemGatewayServicesMock.mockResolvedValueOnce([
|
||||
{
|
||||
platform: "linux",
|
||||
label: "openclaw.service",
|
||||
detail: "unit: /etc/systemd/system/openclaw.service",
|
||||
scope: "system",
|
||||
marker: "openclaw",
|
||||
},
|
||||
]);
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result).toEqual({
|
||||
scope: "system",
|
||||
unitName: "openclaw.service",
|
||||
unitPath: "/etc/systemd/system/openclaw.service",
|
||||
});
|
||||
});
|
||||
|
||||
it("findInstalledSystemdGatewayScope ignores legacy clawdbot system units in the marker fallback", async () => {
|
||||
mockUnitFileLayout({ system: false });
|
||||
findSystemGatewayServicesMock.mockResolvedValueOnce([
|
||||
{
|
||||
platform: "linux",
|
||||
label: "clawdbot.service",
|
||||
detail: "unit: /etc/systemd/system/clawdbot.service",
|
||||
scope: "system",
|
||||
marker: "clawdbot",
|
||||
legacy: true,
|
||||
},
|
||||
]);
|
||||
const result = await findInstalledSystemdGatewayScope({ HOME: TEST_MANAGED_HOME });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("isSystemdServiceEnabled queries the marker-owned custom system unit name", async () => {
|
||||
mockUnitFileLayout({ system: false });
|
||||
findSystemGatewayServicesMock.mockResolvedValueOnce([
|
||||
{
|
||||
platform: "linux",
|
||||
label: "openclaw.service",
|
||||
detail: "unit: /etc/systemd/system/openclaw.service",
|
||||
scope: "system",
|
||||
marker: "openclaw",
|
||||
},
|
||||
]);
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["is-enabled", "openclaw.service"]);
|
||||
cb(null, "enabled\n", "");
|
||||
});
|
||||
await expect(isSystemdServiceEnabled({ env: { HOME: TEST_MANAGED_HOME } })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("restartSystemdService surfaces sudo guidance using the marker-owned custom unit name", async () => {
|
||||
mockUnitFileLayout({ system: false });
|
||||
findSystemGatewayServicesMock.mockResolvedValueOnce([
|
||||
{
|
||||
platform: "linux",
|
||||
label: "openclaw.service",
|
||||
detail: "unit: /etc/systemd/system/openclaw.service",
|
||||
scope: "system",
|
||||
marker: "openclaw",
|
||||
},
|
||||
]);
|
||||
mockEffectiveUid(1000);
|
||||
const { stdout, write } = createWritableStreamMock();
|
||||
await expect(
|
||||
restartSystemdService({ stdout, env: { HOME: TEST_MANAGED_HOME } }),
|
||||
).rejects.toThrow(
|
||||
/openclaw\.service is a system-scope unit \(\/etc\/systemd\/system\/openclaw\.service\); run `sudo systemctl restart openclaw\.service`/,
|
||||
);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isSystemdServiceEnabled reports true for an enabled system-scope unit", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["is-enabled", GATEWAY_SERVICE]);
|
||||
cb(null, "enabled\n", "");
|
||||
});
|
||||
await expect(isSystemdServiceEnabled({ env: { HOME: TEST_MANAGED_HOME } })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("isSystemdServiceEnabled reports false for a disabled system-scope unit", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["is-enabled", GATEWAY_SERVICE]);
|
||||
cb(createExecFileError("disabled", { code: 1 }), "disabled\n", "");
|
||||
});
|
||||
await expect(isSystemdServiceEnabled({ env: { HOME: TEST_MANAGED_HOME } })).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("readSystemdServiceRuntime queries the system manager for system-scope units", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args[0]).toBe("show");
|
||||
expect(args).not.toContain("--user");
|
||||
cb(
|
||||
null,
|
||||
[
|
||||
"Id=openclaw-gateway.service",
|
||||
"ActiveState=active",
|
||||
"SubState=running",
|
||||
"MainPID=4242",
|
||||
].join("\n"),
|
||||
"",
|
||||
);
|
||||
});
|
||||
const runtime = await readSystemdServiceRuntime({ HOME: TEST_MANAGED_HOME });
|
||||
expect(runtime.status).toBe("running");
|
||||
expect(runtime.pid).toBe(4242);
|
||||
expect(runtime.systemd?.unit).toBe("openclaw-gateway.service");
|
||||
});
|
||||
|
||||
it("restartSystemdService refuses to use the user manager when the unit is system-scope and the caller is not root", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
mockEffectiveUid(1000);
|
||||
const { stdout, write } = createWritableStreamMock();
|
||||
await expect(
|
||||
restartSystemdService({ stdout, env: { HOME: TEST_MANAGED_HOME } }),
|
||||
).rejects.toThrow(
|
||||
/system-scope unit .* run `sudo systemctl restart openclaw-gateway\.service`/,
|
||||
);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restartSystemdService restarts the system unit directly when running as root", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
mockEffectiveUid(0);
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["restart", GATEWAY_SERVICE]);
|
||||
cb(null, "", "");
|
||||
});
|
||||
const { stdout, write } = createWritableStreamMock();
|
||||
const result = await restartSystemdService({ stdout, env: { HOME: TEST_MANAGED_HOME } });
|
||||
expect(result).toEqual({ outcome: "completed" });
|
||||
expect(requireFirstWrite(write)).toContain("Restarted systemd service");
|
||||
});
|
||||
|
||||
it("stopSystemdService surfaces sudo guidance for system-scope units without root", async () => {
|
||||
mockUnitFileLayout({ system: "/etc/systemd/system/openclaw-gateway.service" });
|
||||
mockEffectiveUid(1000);
|
||||
const { stdout } = createWritableStreamMock();
|
||||
await expect(stopSystemdService({ stdout, env: { HOME: TEST_MANAGED_HOME } })).rejects.toThrow(
|
||||
/sudo systemctl stop openclaw-gateway\.service/,
|
||||
);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNonFatalSystemdInstallProbeError", () => {
|
||||
it("matches wrapper-only WSL install probe failures", () => {
|
||||
expect(
|
||||
|
||||
@@ -78,6 +78,85 @@ export function resolveSystemdUserUnitPath(env: GatewayServiceEnv): string {
|
||||
return resolveSystemdUnitPath(env);
|
||||
}
|
||||
|
||||
const SYSTEM_SYSTEMD_UNIT_DIRS = [
|
||||
"/etc/systemd/system",
|
||||
"/usr/lib/systemd/system",
|
||||
"/lib/systemd/system",
|
||||
] as const;
|
||||
|
||||
async function findSystemSystemdUnitPath(env: GatewayServiceEnv): Promise<string | null> {
|
||||
const serviceFile = `${resolveSystemdServiceName(env)}.service`;
|
||||
for (const dir of SYSTEM_SYSTEMD_UNIT_DIRS) {
|
||||
const candidate = path.posix.join(dir, serviceFile);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type InstalledSystemdGatewayScope = {
|
||||
scope: SystemdUnitScope;
|
||||
unitName: string;
|
||||
unitPath: string;
|
||||
};
|
||||
|
||||
async function findMarkerOwnedSystemSystemdUnit(): Promise<{
|
||||
unitName: string;
|
||||
unitPath: string;
|
||||
} | null> {
|
||||
const { findSystemGatewayServices } = await import("./inspect.js");
|
||||
let services: Awaited<ReturnType<typeof findSystemGatewayServices>>;
|
||||
try {
|
||||
services = await findSystemGatewayServices();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const svc of services) {
|
||||
if (
|
||||
svc.platform !== "linux" ||
|
||||
svc.scope !== "system" ||
|
||||
svc.marker !== "openclaw" ||
|
||||
!svc.label?.endsWith(".service")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const match = /^unit:\s*(.+)$/.exec(svc.detail.trim());
|
||||
const unitPath = match?.[1]?.trim();
|
||||
if (unitPath) {
|
||||
return { unitName: svc.label, unitPath };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findInstalledSystemdGatewayScope(
|
||||
env: GatewayServiceEnv,
|
||||
): Promise<InstalledSystemdGatewayScope | null> {
|
||||
const canonicalUnitName = `${resolveSystemdServiceName(env)}.service`;
|
||||
let userPath: string | null = null;
|
||||
try {
|
||||
userPath = resolveSystemdUnitPath(env);
|
||||
} catch {
|
||||
userPath = null;
|
||||
}
|
||||
if (userPath) {
|
||||
try {
|
||||
await fs.access(userPath);
|
||||
return { scope: "user", unitName: canonicalUnitName, unitPath: userPath };
|
||||
} catch {}
|
||||
}
|
||||
const systemPath = await findSystemSystemdUnitPath(env);
|
||||
if (systemPath) {
|
||||
return { scope: "system", unitName: canonicalUnitName, unitPath: systemPath };
|
||||
}
|
||||
const owned = await findMarkerOwnedSystemSystemdUnit();
|
||||
return owned ? { scope: "system", unitName: owned.unitName, unitPath: owned.unitPath } : null;
|
||||
}
|
||||
|
||||
export { enableSystemdUserLinger, readSystemdUserLingerStatus };
|
||||
|
||||
// Unit file parsing/rendering: see systemd-unit.ts
|
||||
@@ -1037,6 +1116,17 @@ export async function uninstallSystemdService({
|
||||
}
|
||||
}
|
||||
|
||||
function isRunningAsRoot(): boolean {
|
||||
if (typeof process.geteuid === "function") {
|
||||
try {
|
||||
return process.geteuid() === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runSystemdServiceAction(params: {
|
||||
stdout: NodeJS.WritableStream;
|
||||
env?: GatewayServiceEnv;
|
||||
@@ -1044,9 +1134,22 @@ async function runSystemdServiceAction(params: {
|
||||
label: string;
|
||||
}) {
|
||||
const env = params.env ?? process.env;
|
||||
const installed = await findInstalledSystemdGatewayScope(env);
|
||||
const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`;
|
||||
if (installed?.scope === "system") {
|
||||
if (!isRunningAsRoot()) {
|
||||
throw new Error(
|
||||
`${unitName} is a system-scope unit (${installed.unitPath}); run \`sudo systemctl ${params.action} ${unitName}\` to ${params.action} it`,
|
||||
);
|
||||
}
|
||||
const res = await execSystemctl([params.action, unitName], env);
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`systemctl ${params.action} failed: ${res.stderr || res.stdout}`.trim());
|
||||
}
|
||||
params.stdout.write(`${formatLine(params.label, unitName)}\n`);
|
||||
return;
|
||||
}
|
||||
await assertSystemdAvailable(env);
|
||||
const serviceName = resolveSystemdServiceName(env);
|
||||
const unitName = `${serviceName}.service`;
|
||||
const res = await execSystemctlUser(env, [params.action, unitName]);
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`systemctl ${params.action} failed: ${res.stderr || res.stdout}`.trim());
|
||||
@@ -1081,18 +1184,14 @@ export async function restartSystemdService({
|
||||
|
||||
export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise<boolean> {
|
||||
const env = args.env ?? process.env;
|
||||
try {
|
||||
await fs.access(resolveSystemdUnitPath(env));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
const installed = await findInstalledSystemdGatewayScope(env);
|
||||
if (!installed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serviceName = resolveSystemdServiceName(env);
|
||||
const unitName = `${serviceName}.service`;
|
||||
const res = await execSystemctlUser(env, ["is-enabled", unitName]);
|
||||
const res =
|
||||
installed.scope === "system"
|
||||
? await execSystemctl(["is-enabled", installed.unitName], env)
|
||||
: await execSystemctlUser(env, ["is-enabled", installed.unitName]);
|
||||
if (res.code === 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -1106,23 +1205,29 @@ export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Prom
|
||||
export async function readSystemdServiceRuntime(
|
||||
env: GatewayServiceEnv = process.env as GatewayServiceEnv,
|
||||
): Promise<GatewayServiceRuntime> {
|
||||
try {
|
||||
await assertSystemdAvailable(env);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: formatErrorMessage(err),
|
||||
};
|
||||
const installed = await findInstalledSystemdGatewayScope(env).catch(() => null);
|
||||
if (installed?.scope !== "system") {
|
||||
try {
|
||||
await assertSystemdAvailable(env);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: formatErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
const serviceName = resolveSystemdServiceName(env);
|
||||
const unitName = `${serviceName}.service`;
|
||||
const res = await execSystemctlUser(env, [
|
||||
const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`;
|
||||
const showArgs = [
|
||||
"show",
|
||||
unitName,
|
||||
"--no-page",
|
||||
"--property",
|
||||
"Id,ActiveState,SubState,MainPID,ExecMainStatus,ExecMainCode,KillMode,TasksCurrent,MemoryCurrent",
|
||||
]);
|
||||
];
|
||||
const res =
|
||||
installed?.scope === "system"
|
||||
? await execSystemctl(showArgs, env)
|
||||
: await execSystemctlUser(env, showArgs);
|
||||
if (res.code !== 0) {
|
||||
const detail = (res.stderr || res.stdout).trim();
|
||||
const missing = normalizeLowercaseStringOrEmpty(detail).includes("not found");
|
||||
|
||||
Reference in New Issue
Block a user