diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 1d72adaaf43..0041107264a 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -25,6 +25,10 @@ type ExecFileError = Error & { code?: string | number; }; +const TEST_SERVICE_HOME = "/home/test"; +const TEST_MANAGED_HOME = "/tmp/openclaw-test-home"; +const GATEWAY_SERVICE = "openclaw-gateway.service"; + const createExecFileError = ( message: string, options: { stderr?: string; code?: string | number } = {}, @@ -58,6 +62,48 @@ function pathLikeToString(pathname: unknown): string { return ""; } +function assertUserSystemctlArgs(args: string[], ...command: string[]) { + expect(args).toEqual(["--user", ...command]); +} + +function assertMachineUserSystemctlArgs(args: string[], user: string, ...command: string[]) { + expect(args).toEqual(["--machine", `${user}@`, "--user", ...command]); +} + +async function readManagedServiceEnabled(env: NodeJS.ProcessEnv = { HOME: TEST_MANAGED_HOME }) { + const { isSystemdServiceEnabled } = await import("./systemd.js"); + vi.spyOn(fs, "access").mockResolvedValue(undefined); + return isSystemdServiceEnabled({ env }); +} + +function mockReadGatewayServiceFile( + unitLines: string[], + extraFiles: Record = {}, +) { + return vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { + const pathValue = pathLikeToString(pathname); + if (pathValue.endsWith(`/${GATEWAY_SERVICE}`)) { + return unitLines.join("\n"); + } + const extraFile = extraFiles[pathValue]; + if (typeof extraFile === "string") { + return extraFile; + } + if (extraFile instanceof Error) { + throw extraFile; + } + throw new Error(`unexpected readFile path: ${pathValue}`); + }); +} + +async function expectExecStartWithoutEnvironment(envFileLine: string) { + mockReadGatewayServiceFile(["[Service]", "ExecStart=/usr/bin/openclaw gateway run", envFileLine]); + + const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME }); + expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]); + expect(command?.environment).toBeUndefined(); +} + const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { const { write, stdout } = createWritableStreamMock(); await restartSystemdService({ stdout, env }); @@ -118,24 +164,18 @@ describe("systemd availability", () => { }); describe("isSystemdServiceEnabled", () => { - const mockManagedUnitPresent = () => { - vi.spyOn(fs, "access").mockResolvedValue(undefined); - }; - beforeEach(() => { vi.restoreAllMocks(); execFileMock.mockReset(); }); it("returns false when systemctl is not present", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { const err = new Error("spawn systemctl EACCES") as Error & { code?: string }; err.code = "EACCES"; cb(err, "", ""); }); - const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + const result = await readManagedServiceEnabled(); expect(result).toBe(false); }); @@ -152,55 +192,45 @@ describe("isSystemdServiceEnabled", () => { }); it("calls systemctl is-enabled when systemctl is present", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE); cb(null, "enabled", ""); }); - const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + const result = await readManagedServiceEnabled(); expect(result).toBe(true); }); it("returns false when systemctl reports disabled", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { const err = new Error("disabled") as Error & { code?: number }; err.code = 1; cb(err, "disabled", ""); }); - const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }); + const result = await readManagedServiceEnabled(); expect(result).toBe(false); }); it("returns false for the WSL2 Ubuntu 24.04 wrapper-only is-enabled failure", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE); const err = new Error( - "Command failed: systemctl --user is-enabled openclaw-gateway.service", + `Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`, ) as Error & { code?: number }; err.code = 1; cb(err, "", ""); }); - await expect( - isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }), - ).rejects.toThrow( - "systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled openclaw-gateway.service", + await expect(readManagedServiceEnabled()).rejects.toThrow( + `systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`, ); }); it("returns false when is-enabled cannot connect to the user bus without machine fallback", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); vi.spyOn(os, "userInfo").mockImplementationOnce(() => { throw new Error("no user info"); }); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE); cb( createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }), "", @@ -209,18 +239,14 @@ describe("isSystemdServiceEnabled", () => { }); await expect( - isSystemdServiceEnabled({ - env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" }, - }), + readManagedServiceEnabled({ HOME: TEST_MANAGED_HOME, USER: "", LOGNAME: "" }), ).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to bus"); }); it("returns false when both direct and machine-scope is-enabled checks report bus unavailability", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE); cb( createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }), "", @@ -228,13 +254,7 @@ describe("isSystemdServiceEnabled", () => { ); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual([ - "--machine", - "debian@", - "--user", - "is-enabled", - "openclaw-gateway.service", - ]); + assertMachineUserSystemctlArgs(args, "debian", "is-enabled", GATEWAY_SERVICE); cb( createExecFileError("Failed to connect to user scope bus via local transport", { stderr: @@ -246,32 +266,28 @@ describe("isSystemdServiceEnabled", () => { }); await expect( - isSystemdServiceEnabled({ - env: { HOME: "/tmp/openclaw-test-home", USER: "debian" }, - }), + readManagedServiceEnabled({ HOME: TEST_MANAGED_HOME, USER: "debian" }), ).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to user scope bus"); }); it("throws when generic wrapper errors report infrastructure failures", async () => { - const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE); const err = new Error( - "Command failed: systemctl --user is-enabled openclaw-gateway.service", + `Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`, ) as Error & { code?: number }; err.code = 1; cb(err, "", "read-only file system"); }); - await expect( - isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }), - ).rejects.toThrow("systemctl is-enabled unavailable: read-only file system"); + await expect(readManagedServiceEnabled()).rejects.toThrow( + "systemctl is-enabled unavailable: read-only file system", + ); }); it("throws when systemctl is-enabled fails for non-state errors", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); + vi.spyOn(fs, "access").mockResolvedValue(undefined); execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); @@ -294,7 +310,7 @@ describe("isSystemdServiceEnabled", () => { it("returns false when systemctl is-enabled exits with code 4 (not-found)", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); - mockManagedUnitPresent(); + vi.spyOn(fs, "access").mockResolvedValue(undefined); execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { // On Ubuntu 24.04, `systemctl --user is-enabled ` exits with // code 4 and prints "not-found" to stdout when the unit doesn't exist. @@ -463,82 +479,38 @@ describe("readSystemdServiceExecStart", () => { }); it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => { - const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { - const pathValue = pathLikeToString(pathname); - if (pathValue.endsWith("/openclaw-gateway.service")) { - return [ - "[Service]", - "ExecStart=/usr/bin/openclaw gateway run", - "EnvironmentFile=%h/.openclaw/.env", - ].join("\n"); - } - if (pathValue === "/home/test/.openclaw/.env") { - return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n"; - } - throw new Error(`unexpected readFile path: ${pathValue}`); - }); + const readFileSpy = mockReadGatewayServiceFile( + ["[Service]", "ExecStart=/usr/bin/openclaw gateway run", "EnvironmentFile=%h/.openclaw/.env"], + { [`${TEST_SERVICE_HOME}/.openclaw/.env`]: "OPENCLAW_GATEWAY_TOKEN=env-file-token\n" }, + ); - const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME }); expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token"); expect(readFileSpy).toHaveBeenCalledTimes(2); }); it("lets EnvironmentFile override inline Environment values", async () => { - vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { - const pathValue = pathLikeToString(pathname); - if (pathValue.endsWith("/openclaw-gateway.service")) { - return [ - "[Service]", - "ExecStart=/usr/bin/openclaw gateway run", - "EnvironmentFile=%h/.openclaw/.env", - 'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"', - ].join("\n"); - } - if (pathValue === "/home/test/.openclaw/.env") { - return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n"; - } - throw new Error(`unexpected readFile path: ${pathValue}`); - }); + mockReadGatewayServiceFile( + [ + "[Service]", + "ExecStart=/usr/bin/openclaw gateway run", + "EnvironmentFile=%h/.openclaw/.env", + 'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"', + ], + { [`${TEST_SERVICE_HOME}/.openclaw/.env`]: "OPENCLAW_GATEWAY_TOKEN=env-file-token\n" }, + ); - const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); + const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME }); expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token"); expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBe("file"); }); it("ignores missing optional EnvironmentFile entries", async () => { - vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { - const pathValue = pathLikeToString(pathname); - if (pathValue.endsWith("/openclaw-gateway.service")) { - return [ - "[Service]", - "ExecStart=/usr/bin/openclaw gateway run", - "EnvironmentFile=-%h/.openclaw/missing.env", - ].join("\n"); - } - throw new Error(`missing: ${pathValue}`); - }); - - const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); - expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]); - expect(command?.environment).toBeUndefined(); + await expectExecStartWithoutEnvironment("EnvironmentFile=-%h/.openclaw/missing.env"); }); it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => { - vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { - const pathValue = pathLikeToString(pathname); - if (pathValue.endsWith("/openclaw-gateway.service")) { - return [ - "[Service]", - "ExecStart=/usr/bin/openclaw gateway run", - "EnvironmentFile=%h/.openclaw/missing.env", - ].join("\n"); - } - throw new Error(`missing: ${pathValue}`); - }); - - const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); - expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]); - expect(command?.environment).toBeUndefined(); + await expectExecStartWithoutEnvironment("EnvironmentFile=%h/.openclaw/missing.env"); }); it("supports multiple EnvironmentFile entries and quoted paths", async () => { @@ -631,7 +603,7 @@ describe("readSystemdServiceExecStart", () => { describe("systemd service control", () => { const assertMachineRestartArgs = (args: string[]) => { - expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]); + assertMachineUserSystemctlArgs(args, "debian", "restart", GATEWAY_SERVICE); }; beforeEach(() => { @@ -642,7 +614,7 @@ describe("systemd service control", () => { execFileMock .mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", "")) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "stop", GATEWAY_SERVICE); cb(null, "", ""); }); const write = vi.fn(); @@ -664,7 +636,7 @@ describe("systemd service control", () => { ), ) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "stop", GATEWAY_SERVICE); cb(null, "", ""); }); @@ -678,7 +650,7 @@ describe("systemd service control", () => { execFileMock .mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", "")) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "restart", "openclaw-gateway-work.service"]); + assertUserSystemctlArgs(args, "restart", "openclaw-gateway-work.service"); cb(null, "", ""); }); await assertRestartSuccess({ OPENCLAW_PROFILE: "work" }); @@ -724,7 +696,7 @@ describe("systemd service control", () => { it("targets the sudo caller's user scope when SUDO_USER is set", async () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--machine", "debian@", "--user", "status"]); + assertMachineUserSystemctlArgs(args, "debian", "status"); cb(null, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { @@ -737,11 +709,11 @@ describe("systemd service control", () => { it("keeps direct --user scope when SUDO_USER is root", async () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "status"]); + assertUserSystemctlArgs(args, "status"); cb(null, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "restart", GATEWAY_SERVICE); cb(null, "", ""); }); await assertRestartSuccess({ SUDO_USER: "root", USER: "root" }); @@ -750,7 +722,7 @@ describe("systemd service control", () => { it("falls back to machine user scope for restart when user bus env is missing", async () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "status"]); + assertUserSystemctlArgs(args, "status"); const err = createExecFileError("Failed to connect to user scope bus", { stderr: "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", @@ -758,11 +730,11 @@ describe("systemd service control", () => { cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--machine", "debian@", "--user", "status"]); + assertMachineUserSystemctlArgs(args, "debian", "status"); cb(null, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); + assertUserSystemctlArgs(args, "restart", GATEWAY_SERVICE); const err = createExecFileError("Failed to connect to user scope bus", { stderr: "Failed to connect to user scope bus", });