diff --git a/CHANGELOG.md b/CHANGELOG.md index df4c46920e8..d1e542b11d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 9a8f797e181..247a93c18fd 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -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; + | NonNullable; export type AgentHarness = { id: string; diff --git a/src/daemon/systemd-hints.test.ts b/src/daemon/systemd-hints.test.ts index 02fa7c1cdad..8191caba78c 100644 --- a/src/daemon/systemd-hints.test.ts +++ b/src/daemon/systemd-hints.test.ts @@ -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.", diff --git a/src/daemon/systemd-unavailable.test.ts b/src/daemon/systemd-unavailable.test.ts index c94eed281f8..e245dae9436 100644 --- a/src/daemon/systemd-unavailable.test.ts +++ b/src/daemon/systemd-unavailable.test.ts @@ -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", () => { diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 110da192f60..0bace19ca6e 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -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"); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 26edba280e2..f68abcba5fe 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -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. diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index 5897b8cb356..6701df97681 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -33,6 +33,12 @@ vi.mock("./config-state.js", () => ({ }) => ({ activated: params.config?.entries?.[params.id]?.enabled !== false, }), + resolveEffectiveEnableState: (params: { + config?: { entries?: Record }; + id: string; + }) => ({ + enabled: params.config?.entries?.[params.id]?.enabled !== false, + }), })); const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js"); diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 703f8c5e9e1..e493e84b625 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -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: { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index dbcefa6142f..4bce34f90b7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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 {