mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 06:00:43 +00:00
fix(daemon): handle sudo user-systemd gateway install failures
* fix(daemon): handle sudo user-systemd gateway install failures * fix(daemon): harden sudo systemctl user scope * fix(plugins): remove static type-cycle edges * test(plugins): update bundle command config mock
This commit is contained in:
@@ -8,6 +8,11 @@ describe("isSystemdUnavailableDetail", () => {
|
||||
isSystemdUnavailableDetail("systemctl --user unavailable: Failed to connect to bus"),
|
||||
).toBe(true);
|
||||
expect(isSystemdUnavailableDetail("systemctl --user unavailable: ENOMEDIUM")).toBe(true);
|
||||
expect(
|
||||
isSystemdUnavailableDetail(
|
||||
"systemctl --user unavailable: Failed to connect to bus: Permission denied",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSystemdUnavailableDetail(
|
||||
"systemctl not available; systemd user services are required on Linux.",
|
||||
|
||||
@@ -22,6 +22,11 @@ describe("classifySystemdUnavailableDetail", () => {
|
||||
"systemctl --user unavailable: Failed to connect to bus: No medium found",
|
||||
),
|
||||
).toBe("user_bus_unavailable");
|
||||
expect(
|
||||
classifySystemdUnavailableDetail(
|
||||
"systemctl --user unavailable: Failed to connect to bus: Permission denied",
|
||||
),
|
||||
).toBe("user_bus_unavailable");
|
||||
});
|
||||
|
||||
it("classifies generic systemd-unavailable details", () => {
|
||||
|
||||
@@ -81,6 +81,10 @@ function assertMachineUserSystemctlArgs(args: string[], user: string, ...command
|
||||
expect(args).toEqual(["--machine", `${user}@`, "--user", ...command]);
|
||||
}
|
||||
|
||||
function mockEffectiveUid(uid: number) {
|
||||
vi.spyOn(process, "geteuid").mockReturnValue(uid);
|
||||
}
|
||||
|
||||
async function readManagedServiceEnabled(env: NodeJS.ProcessEnv = { HOME: TEST_MANAGED_HOME }) {
|
||||
vi.spyOn(fs, "access").mockResolvedValue(undefined);
|
||||
return isSystemdServiceEnabled({ env });
|
||||
@@ -190,6 +194,7 @@ describe("systemd availability", () => {
|
||||
});
|
||||
|
||||
it("does not fall back to direct --user when machine scope fails under sudo", async () => {
|
||||
mockEffectiveUid(0);
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertMachineUserSystemctlArgs(args, "ai", "status");
|
||||
cb(
|
||||
@@ -205,6 +210,36 @@ describe("systemd availability", () => {
|
||||
await expect(isSystemdUserServiceAvailable({ SUDO_USER: "ai" })).resolves.toBe(false);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let preserved USER suppress sudo-to-root machine scope", async () => {
|
||||
mockEffectiveUid(0);
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertMachineUserSystemctlArgs(args, "debian", "status");
|
||||
cb(null, "", "");
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdUserServiceAvailable({
|
||||
SUDO_USER: "debian",
|
||||
USER: "root-env-stale",
|
||||
LOGNAME: "root-env-stale",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let stale SUDO_USER override a sudo-u target user scope", async () => {
|
||||
mockEffectiveUid(1000);
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertUserSystemctlArgs(args, "status");
|
||||
cb(null, "", "");
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdUserServiceAvailable({ USER: "openclaw", SUDO_USER: "admin" }),
|
||||
).resolves.toBe(true);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSystemdServiceEnabled", () => {
|
||||
@@ -907,6 +942,51 @@ describe("systemd service install and uninstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the sudo-u target user for install activation machine-scope retry", async () => {
|
||||
await withNodeSystemdFixture(async ({ env }) => {
|
||||
const installEnv = { ...env, USER: "openclaw", SUDO_USER: "admin" };
|
||||
execFileMock
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertUserSystemctlArgs(args, "status");
|
||||
cb(null, "", "");
|
||||
})
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertUserSystemctlArgs(args, "daemon-reload");
|
||||
cb(null, "", "");
|
||||
})
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertUserSystemctlArgs(args, "enable", NODE_SERVICE);
|
||||
cb(
|
||||
createExecFileError("Failed to connect to bus: No medium found", {
|
||||
stderr: "Failed to connect to bus: No medium found",
|
||||
}),
|
||||
"",
|
||||
"",
|
||||
);
|
||||
})
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertMachineUserSystemctlArgs(args, "openclaw", "enable", NODE_SERVICE);
|
||||
cb(null, "", "");
|
||||
})
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertUserSystemctlArgs(args, "restart", NODE_SERVICE);
|
||||
cb(null, "", "");
|
||||
});
|
||||
|
||||
await installSystemdService({
|
||||
env: installEnv,
|
||||
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
|
||||
programArguments: ["/usr/bin/openclaw", "node", "run"],
|
||||
workingDirectory: "/tmp",
|
||||
environment: {
|
||||
OPENCLAW_SYSTEMD_UNIT: "openclaw-node",
|
||||
},
|
||||
});
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces install activation user-bus failures as systemd unavailable errors", async () => {
|
||||
await withNodeSystemdFixture(async ({ env }) => {
|
||||
vi.spyOn(os, "userInfo").mockImplementation(() => {
|
||||
@@ -1066,6 +1146,7 @@ describe("systemd service control", () => {
|
||||
});
|
||||
|
||||
it("targets the sudo caller's user scope when SUDO_USER is set", async () => {
|
||||
mockEffectiveUid(0);
|
||||
execFileMock
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
assertMachineUserSystemctlArgs(args, "debian", "status");
|
||||
|
||||
@@ -342,15 +342,11 @@ function resolveSystemctlDirectUserScopeArgs(): string[] {
|
||||
return ["--user"];
|
||||
}
|
||||
|
||||
function resolveSystemctlMachineScopeUser(env: GatewayServiceEnv): string | null {
|
||||
const sudoUser = env.SUDO_USER?.trim();
|
||||
if (sudoUser && sudoUser !== "root") {
|
||||
return sudoUser;
|
||||
}
|
||||
const fromEnv = env.USER?.trim() || env.LOGNAME?.trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv;
|
||||
}
|
||||
function readSystemctlEnvUser(env: GatewayServiceEnv): string | null {
|
||||
return env.USER?.trim() || env.LOGNAME?.trim() || null;
|
||||
}
|
||||
|
||||
function readSystemctlEffectiveUser(): string | null {
|
||||
try {
|
||||
return os.userInfo().username;
|
||||
} catch {
|
||||
@@ -358,6 +354,44 @@ function resolveSystemctlMachineScopeUser(env: GatewayServiceEnv): string | null
|
||||
}
|
||||
}
|
||||
|
||||
function readSystemctlEffectiveUid(): number | null {
|
||||
if (typeof process.geteuid !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return process.geteuid();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonRootUser(user: string | null): user is string {
|
||||
return Boolean(user && user !== "root");
|
||||
}
|
||||
|
||||
function resolveSystemctlUserScope(env: GatewayServiceEnv): {
|
||||
machineUser: string | null;
|
||||
preferMachineScope: boolean;
|
||||
} {
|
||||
const sudoUser = env.SUDO_USER?.trim() || null;
|
||||
const envUser = readSystemctlEnvUser(env);
|
||||
const effectiveUid = readSystemctlEffectiveUid();
|
||||
const effectiveUser = readSystemctlEffectiveUser();
|
||||
const isEffectiveRoot = effectiveUid === null ? effectiveUser === "root" : effectiveUid === 0;
|
||||
const isSudoToRoot = isEffectiveRoot && isNonRootUser(sudoUser);
|
||||
const machineUser = isSudoToRoot
|
||||
? sudoUser
|
||||
: isNonRootUser(envUser)
|
||||
? envUser
|
||||
: isNonRootUser(sudoUser)
|
||||
? sudoUser
|
||||
: effectiveUser || envUser || sudoUser || null;
|
||||
return {
|
||||
machineUser,
|
||||
preferMachineScope: isSudoToRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSystemctlMachineUserScopeArgs(user: string): string[] {
|
||||
const trimmedUser = user.trim();
|
||||
if (!trimmedUser) {
|
||||
@@ -380,11 +414,10 @@ async function execSystemctlUser(
|
||||
env: GatewayServiceEnv,
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
const machineUser = resolveSystemctlMachineScopeUser(env);
|
||||
const sudoUser = env.SUDO_USER?.trim();
|
||||
const { machineUser, preferMachineScope } = resolveSystemctlUserScope(env);
|
||||
|
||||
// Under sudo, prefer the invoking non-root user's scope directly via machine scope.
|
||||
if (sudoUser && sudoUser !== "root" && machineUser) {
|
||||
// Under sudo-to-root, prefer the invoking non-root user's scope directly via machine scope.
|
||||
if (preferMachineScope && machineUser) {
|
||||
const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser);
|
||||
if (machineScopeArgs.length > 0) {
|
||||
// Do not fall through to bare --user: under sudo that can target root's user manager.
|
||||
|
||||
Reference in New Issue
Block a user