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:
Vincent Koc
2026-04-27 01:34:57 -07:00
committed by GitHub
parent c25082f92e
commit 56ca4e2269
9 changed files with 159 additions and 35 deletions

View File

@@ -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.",

View File

@@ -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", () => {

View File

@@ -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");

View File

@@ -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.