mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Fix Linux daemon install checks when systemd user bus env is missing (#34884)
* daemon(systemd): fall back to machine user scope when user bus is missing * test(systemd): cover machine scope fallback for user-bus errors * test(systemd): reset execFile mock state across cases * test(systemd): make machine-user fallback assertion portable * fix(daemon): keep root sudo path on direct user scope * test(systemd): cover sudo root user-scope behavior * ci: use resolvable bun version in setup-node-env
This commit is contained in:
2
.github/actions/setup-node-env/action.yml
vendored
2
.github/actions/setup-node-env/action.yml
vendored
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user