diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 1b70385ca54..c46387517e4 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -61,7 +61,7 @@ runs: if: inputs.install-bun == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.9+cf6cdbbba" + bun-version: "1.3.9" - name: Runtime versions shell: bash diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index ec1b3b78da2..71bfef54d6d 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -18,7 +18,7 @@ import { describe("systemd availability", () => { beforeEach(() => { - execFileMock.mockClear(); + execFileMock.mockReset(); }); it("returns true when systemctl --user succeeds", async () => { @@ -40,11 +40,34 @@ describe("systemd availability", () => { }); await expect(isSystemdUserServiceAvailable()).resolves.toBe(false); }); + + it("falls back to machine user scope when --user bus is unavailable", async () => { + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "status"]); + const err = new Error( + "Failed to connect to user scope bus via local transport", + ) as Error & { + stderr?: string; + code?: number; + }; + err.stderr = + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; + err.code = 1; + cb(err, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--machine", "debian@", "--user", "status"]); + cb(null, "", ""); + }); + + await expect(isSystemdUserServiceAvailable({ USER: "debian" })).resolves.toBe(true); + }); }); describe("isSystemdServiceEnabled", () => { beforeEach(() => { - execFileMock.mockClear(); + execFileMock.mockReset(); }); it("returns false when systemctl is not present", async () => { @@ -81,13 +104,23 @@ describe("isSystemdServiceEnabled", () => { it("throws when systemctl is-enabled fails for non-state errors", async () => { const { isSystemdServiceEnabled } = await import("./systemd.js"); - execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { - const err = new Error("Failed to connect to bus") as Error & { code?: number }; - err.code = 1; - cb(err, "", "Failed to connect to bus"); - }); + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + const err = new Error("Failed to connect to bus") as Error & { code?: number }; + err.code = 1; + cb(err, "", "Failed to connect to bus"); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args[0]).toBe("--machine"); + expect(String(args[1])).toMatch(/^[^@]+@$/); + expect(args.slice(2)).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]); + const err = new Error("permission denied") as Error & { code?: number }; + err.code = 1; + cb(err, "", "permission denied"); + }); await expect(isSystemdServiceEnabled({ env: {} })).rejects.toThrow( - "systemctl is-enabled unavailable: Failed to connect to bus", + "systemctl is-enabled unavailable: permission denied", ); }); @@ -216,7 +249,7 @@ describe("parseSystemdExecStart", () => { describe("systemd service control", () => { beforeEach(() => { - execFileMock.mockClear(); + execFileMock.mockReset(); }); it("stops the resolved user unit", async () => { @@ -292,4 +325,69 @@ describe("systemd service control", () => { expect(write).toHaveBeenCalledTimes(1); expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); }); + + it("keeps direct --user scope when SUDO_USER is root", async () => { + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "status"]); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); + cb(null, "", ""); + }); + const write = vi.fn(); + const stdout = { write } as unknown as NodeJS.WritableStream; + + await restartSystemdService({ stdout, env: { SUDO_USER: "root", USER: "root" } }); + + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + }); + + 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"]); + const err = new Error("Failed to connect to user scope bus") as Error & { + stderr?: string; + code?: number; + }; + err.stderr = + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; + err.code = 1; + cb(err, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--machine", "debian@", "--user", "status"]); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); + const err = new Error("Failed to connect to user scope bus") as Error & { + stderr?: string; + code?: number; + }; + err.stderr = "Failed to connect to user scope bus"; + err.code = 1; + cb(err, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + expect(args).toEqual([ + "--machine", + "debian@", + "--user", + "restart", + "openclaw-gateway.service", + ]); + cb(null, "", ""); + }); + const write = vi.fn(); + const stdout = { write } as unknown as NodeJS.WritableStream; + + await restartSystemdService({ stdout, env: { USER: "debian" } }); + + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 55657561da4..08353048c59 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, @@ -178,19 +179,74 @@ function isSystemdUnitNotEnabled(detail: string): boolean { ); } -function resolveSystemctlUserScopeArgs(env: GatewayServiceEnv): string[] { +function resolveSystemctlDirectUserScopeArgs(): string[] { + return ["--user"]; +} + +function resolveSystemctlMachineScopeUser(env: GatewayServiceEnv): string | null { const sudoUser = env.SUDO_USER?.trim(); if (sudoUser && sudoUser !== "root") { - return ["--machine", `${sudoUser}@`, "--user"]; + return sudoUser; } - return ["--user"]; + const fromEnv = env.USER?.trim() || env.LOGNAME?.trim(); + if (fromEnv) { + return fromEnv; + } + try { + return os.userInfo().username; + } catch { + return null; + } +} + +function resolveSystemctlMachineUserScopeArgs(user: string): string[] { + const trimmedUser = user.trim(); + if (!trimmedUser) { + return []; + } + return ["--machine", `${trimmedUser}@`, "--user"]; +} + +function shouldFallbackToMachineUserScope(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("failed to connect to bus") || + normalized.includes("failed to connect to user scope bus") || + normalized.includes("dbus_session_bus_address") || + normalized.includes("xdg_runtime_dir") + ); } async function execSystemctlUser( env: GatewayServiceEnv, args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { - return await execSystemctl([...resolveSystemctlUserScopeArgs(env), ...args]); + const machineUser = resolveSystemctlMachineScopeUser(env); + const sudoUser = env.SUDO_USER?.trim(); + + // Under sudo, prefer the invoking non-root user's scope directly. + if (sudoUser && sudoUser !== "root" && machineUser) { + const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); + if (machineScopeArgs.length > 0) { + return await execSystemctl([...machineScopeArgs, ...args]); + } + } + + const directResult = await execSystemctl([...resolveSystemctlDirectUserScopeArgs(), ...args]); + if (directResult.code === 0) { + return directResult; + } + + const detail = `${directResult.stderr} ${directResult.stdout}`.trim(); + if (!machineUser || !shouldFallbackToMachineUserScope(detail)) { + return directResult; + } + + const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); + if (machineScopeArgs.length === 0) { + return directResult; + } + return await execSystemctl([...machineScopeArgs, ...args]); } export async function isSystemdUserServiceAvailable(