mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user