diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e26f32b..85c2b965d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.clawd.bot - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - CLI: avoid duplicating --profile/--dev flags when formatting commands. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. +- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander. ## 2026.1.19-3 diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 0c1c60403..f85ec74c2 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -79,6 +79,9 @@ This intentionally excludes version managers (nvm/fnm/volta/asdf) and package managers (pnpm/npm) because the daemon does not load your shell init. Runtime variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the gateway). +Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment, +so missing tools usually mean your shell init isn’t exporting them (or set +`tools.exec.pathPrepend`). See [/tools/exec](/tools/exec). WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your service was installed with Bun or a version-managed Node path, run `clawdbot doctor` diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 3cf2ada1b..02865060f 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -57,7 +57,8 @@ Example: ### PATH handling -- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`: +- `host=gateway`: merges your login-shell `PATH` into the exec environment (unless the exec call + already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`: - macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` - Linux: `/usr/local/bin`, `/usr/bin`, `/bin` - `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`. diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts new file mode 100644 index 000000000..3400f747e --- /dev/null +++ b/src/agents/bash-tools.exec.path.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { sanitizeBinaryOutput } from "./shell-utils.js"; + +const isWin = process.platform === "win32"; + +vi.mock("../infra/shell-env.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getShellPathFromLoginShell: vi.fn(() => "/custom/bin:/opt/bin"), + resolveShellEnvFallbackTimeoutMs: vi.fn(() => 1234), + }; +}); + +vi.mock("../infra/exec-approvals.js", async (importOriginal) => { + const mod = await importOriginal(); + const approvals: ExecApprovalsResolved = { + path: "/tmp/exec-approvals.json", + socketPath: "/tmp/exec-approvals.sock", + token: "token", + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agent: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + allowlist: [], + file: { + version: 1, + socket: { path: "/tmp/exec-approvals.sock", token: "token" }, + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agents: {}, + }, + }; + return { ...mod, resolveExecApprovals: () => approvals }; +}); + +const normalizeText = (value?: string) => + sanitizeBinaryOutput(value ?? "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .trim(); + +describe("exec PATH login shell merge", () => { + const originalPath = process.env.PATH; + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it("merges login-shell PATH for host=gateway", async () => { + if (isWin) return; + process.env.PATH = "/usr/bin"; + + const { createExecTool } = await import("./bash-tools.exec.js"); + const { getShellPathFromLoginShell } = await import("../infra/shell-env.js"); + const shellPathMock = vi.mocked(getShellPathFromLoginShell); + shellPathMock.mockClear(); + shellPathMock.mockReturnValue("/custom/bin:/opt/bin"); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { command: "echo $PATH" }); + const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + + expect(text).toBe("/custom/bin:/opt/bin:/usr/bin"); + expect(shellPathMock).toHaveBeenCalledTimes(1); + }); + + it("skips login-shell PATH when env.PATH is provided", async () => { + if (isWin) return; + process.env.PATH = "/usr/bin"; + + const { createExecTool } = await import("./bash-tools.exec.js"); + const { getShellPathFromLoginShell } = await import("../infra/shell-env.js"); + const shellPathMock = vi.mocked(getShellPathFromLoginShell); + shellPathMock.mockClear(); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { + command: "echo $PATH", + env: { PATH: "/explicit/bin" }, + }); + const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + + expect(text).toBe("/explicit/bin"); + expect(shellPathMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 47d5c17b3..dc2865ccc 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -18,6 +18,10 @@ import { } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; +import { + getShellPathFromLoginShell, + resolveShellEnvFallbackTimeoutMs, +} from "../infra/shell-env.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { logInfo } from "../logger.js"; import { @@ -249,6 +253,17 @@ function applyPathPrepend( if (merged) env.PATH = merged; } +function applyShellPath(env: Record, shellPath?: string | null) { + if (!shellPath) return; + const entries = shellPath + .split(path.delimiter) + .map((part) => part.trim()) + .filter(Boolean); + if (entries.length === 0) return; + const merged = mergePathPrepend(env.PATH, entries); + if (merged) env.PATH = merged; +} + function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") { if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return; const sessionKey = session.sessionKey?.trim(); @@ -422,6 +437,13 @@ export function createExecTool( containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, }) : mergedEnv; + if (!sandbox && host === "gateway" && !params.env?.PATH) { + const shellPath = getShellPathFromLoginShell({ + env: process.env, + timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env), + }); + applyShellPath(env, shellPath); + } applyPathPrepend(env, defaultPathPrepend); if (host === "node") { diff --git a/src/infra/shell-env.path.test.ts b/src/infra/shell-env.path.test.ts new file mode 100644 index 000000000..0e20e3b1b --- /dev/null +++ b/src/infra/shell-env.path.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell-env.js"; + +describe("getShellPathFromLoginShell", () => { + afterEach(() => resetShellPathCacheForTests()); + + it("returns PATH from login shell env", () => { + if (process.platform === "win32") return; + const exec = vi + .fn() + .mockReturnValue(Buffer.from("PATH=/custom/bin\0HOME=/home/user\0", "utf-8")); + const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec }); + expect(result).toBe("/custom/bin"); + }); + + it("caches the value", () => { + if (process.platform === "win32") return; + const exec = vi.fn().mockReturnValue(Buffer.from("PATH=/custom/bin\0", "utf-8")); + const env = { SHELL: "/bin/sh" } as NodeJS.ProcessEnv; + expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin"); + expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin"); + expect(exec).toHaveBeenCalledTimes(1); + }); + + it("returns null on exec failure", () => { + if (process.platform === "win32") return; + const exec = vi.fn(() => { + throw new Error("boom"); + }); + const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec }); + expect(result).toBeNull(); + }); +}); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index b44b1d836..4ef503829 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -5,12 +5,28 @@ import { isTruthyEnvValue } from "./env.js"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; let lastAppliedKeys: string[] = []; +let cachedShellPath: string | null | undefined; function resolveShell(env: NodeJS.ProcessEnv): string { const shell = env.SHELL?.trim(); return shell && shell.length > 0 ? shell : "/bin/sh"; } +function parseShellEnv(stdout: Buffer): Map { + const shellEnv = new Map(); + const parts = stdout.toString("utf8").split("\0"); + for (const part of parts) { + if (!part) continue; + const eq = part.indexOf("="); + if (eq <= 0) continue; + const key = part.slice(0, eq); + const value = part.slice(eq + 1); + if (!key) continue; + shellEnv.set(key, value); + } + return shellEnv; +} + export type ShellEnvFallbackResult = | { ok: true; applied: string[]; skippedReason?: never } | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } @@ -63,17 +79,7 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal return { ok: false, error: msg, applied: [] }; } - const shellEnv = new Map(); - const parts = stdout.toString("utf8").split("\0"); - for (const part of parts) { - if (!part) continue; - const eq = part.indexOf("="); - if (eq <= 0) continue; - const key = part.slice(0, eq); - const value = part.slice(eq + 1); - if (!key) continue; - shellEnv.set(key, value); - } + const shellEnv = parseShellEnv(stdout); const applied: string[] = []; for (const key of opts.expectedKeys) { @@ -104,6 +110,48 @@ export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number return Math.max(0, parsed); } +export function getShellPathFromLoginShell(opts: { + env: NodeJS.ProcessEnv; + timeoutMs?: number; + exec?: typeof execFileSync; +}): string | null { + if (cachedShellPath !== undefined) return cachedShellPath; + if (process.platform === "win32") { + cachedShellPath = null; + return cachedShellPath; + } + + const exec = opts.exec ?? execFileSync; + const timeoutMs = + typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(0, opts.timeoutMs) + : DEFAULT_TIMEOUT_MS; + const shell = resolveShell(opts.env); + + let stdout: Buffer; + try { + stdout = exec(shell, ["-l", "-c", "env -0"], { + encoding: "buffer", + timeout: timeoutMs, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: opts.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + cachedShellPath = null; + return cachedShellPath; + } + + const shellEnv = parseShellEnv(stdout); + const shellPath = shellEnv.get("PATH")?.trim(); + cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; + return cachedShellPath; +} + +export function resetShellPathCacheForTests(): void { + cachedShellPath = undefined; +} + export function getShellEnvAppliedKeys(): string[] { return [...lastAppliedKeys]; }