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

@@ -48,7 +48,7 @@ Docs: https://docs.openclaw.ai
- Browser/plugins: load `playwright-core` through the browser runtime shim so packaged installs can run Playwright actions from staged plugin runtime deps after doctor/startup repair. Fixes #72168; supersedes #72238. Thanks @zdg1110 and @yetval.
- Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss.
- TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.
- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the machine user scope when `systemctl --user` reports no-medium bus failures. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, and @mssteuer.
- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when `systemctl --user` reports no-medium bus failures, without letting stale `SUDO_USER` override `sudo -u` installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
- Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc.

View File

@@ -1,25 +1,21 @@
import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js";
import type {
EmbeddedRunAttemptParams,
EmbeddedRunAttemptResult,
} from "../pi-embedded-runner/run/types.js";
import type { EmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js";
import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js";
export type AgentHarnessSupportContext = {
provider: string;
modelId?: string;
requestedRuntime: EmbeddedAgentRuntime;
requestedRuntime: import("../pi-embedded-runner/runtime.js").EmbeddedAgentRuntime;
};
export type AgentHarnessSupport =
| { supported: true; priority?: number; reason?: string }
| { supported: false; reason?: string };
export type AgentHarnessAttemptParams = EmbeddedRunAttemptParams;
export type AgentHarnessAttemptResult = EmbeddedRunAttemptResult;
export type AgentHarnessCompactParams = CompactEmbeddedPiSessionParams;
export type AgentHarnessCompactResult = EmbeddedPiCompactResult;
export type AgentHarnessAttemptParams =
import("../pi-embedded-runner/run/types.js").EmbeddedRunAttemptParams;
export type AgentHarnessAttemptResult =
import("../pi-embedded-runner/run/types.js").EmbeddedRunAttemptResult;
export type AgentHarnessCompactParams =
import("../pi-embedded-runner/compact.types.js").CompactEmbeddedPiSessionParams;
export type AgentHarnessCompactResult =
import("../pi-embedded-runner/types.js").EmbeddedPiCompactResult;
export type AgentHarnessResetParams = {
sessionId?: string;
sessionKey?: string;
@@ -29,7 +25,7 @@ export type AgentHarnessResetParams = {
export type AgentHarnessResultClassification =
| "ok"
| NonNullable<EmbeddedRunAttemptResult["agentHarnessResultClassification"]>;
| NonNullable<AgentHarnessAttemptResult["agentHarnessResultClassification"]>;
export type AgentHarness = {
id: string;

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.

View File

@@ -33,6 +33,12 @@ vi.mock("./config-state.js", () => ({
}) => ({
activated: params.config?.entries?.[params.id]?.enabled !== false,
}),
resolveEffectiveEnableState: (params: {
config?: { entries?: Record<string, { enabled?: boolean }> };
id: string;
}) => ({
enabled: params.config?.entries?.[params.id]?.enabled !== false,
}),
}));
const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js");

View File

@@ -1,7 +1,3 @@
import type {
RunEmbeddedAgentFn,
RunEmbeddedPiAgentFn,
} from "../../agents/pi-embedded-runtime.types.js";
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { LogLevel } from "../../logging/levels.js";
import type { MediaUnderstandingRuntime } from "../../media-understanding/runtime-types.js";
@@ -60,8 +56,8 @@ export type PluginRuntimeCore = {
model: string;
catalog?: import("../../agents/model-catalog.types.js").ModelCatalogEntry[];
}) => import("../../auto-reply/thinking.js").ThinkLevel;
runEmbeddedAgent: RunEmbeddedAgentFn;
runEmbeddedPiAgent: RunEmbeddedPiAgentFn;
runEmbeddedAgent: import("../../agents/pi-embedded-runtime.types.js").RunEmbeddedAgentFn;
runEmbeddedPiAgent: import("../../agents/pi-embedded-runtime.types.js").RunEmbeddedPiAgentFn;
resolveAgentTimeoutMs: typeof import("../../agents/timeout.js").resolveAgentTimeoutMs;
ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace;
session: {

View File

@@ -12,7 +12,6 @@ import type {
import type { AgentHarness } from "../agents/harness/types.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js";
import type { ModelProviderRequestTransportOverrides } from "../agents/provider-request-config.js";
import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js";
import type { PromptMode } from "../agents/system-prompt.types.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
@@ -137,6 +136,9 @@ import type {
} from "./tool-types.js";
import type { WebFetchProviderPlugin, WebSearchProviderPlugin } from "./web-provider-types.js";
type ModelProviderRequestTransportOverrides =
import("../agents/provider-request-config.js").ModelProviderRequestTransportOverrides;
export type { PluginRuntime } from "./runtime/types.js";
export type { PluginOrigin } from "./plugin-origin.types.js";
export type {