diff --git a/CHANGELOG.md b/CHANGELOG.md index 63524b033..a20382166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. - TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. +- Exec: add `tools.exec.pathPrepend` for prepending PATH entries on exec runs. ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index a66f2a53f..1a65618e2 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -40,6 +40,28 @@ Notes: - `tools.exec.security` (default: `deny`) - `tools.exec.ask` (default: `on-miss`) - `tools.exec.node` (default: unset) +- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs. + +Example: +```json5 +{ + tools: { + exec: { + pathPrepend: ["~/bin", "/opt/oss/bin"] + } + } +} +``` + +### PATH handling + +- `host=gateway`: uses the Gateway process `PATH`. Daemons install 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`. + Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too. +- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies + if the exec call already sets `env.PATH`. Per-agent node binding (use the agent list index in config): diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 3713e5bf4..316a54040 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -89,6 +90,7 @@ export type ExecToolDefaults = { security?: ExecSecurity; ask?: ExecAsk; node?: string; + pathPrepend?: string[]; agentId?: string; backgroundMs?: number; timeoutSec?: number; @@ -207,6 +209,47 @@ function normalizeNotifyOutput(value: string) { return value.replace(/\s+/g, " ").trim(); } +function normalizePathPrepend(entries?: string[]) { + if (!Array.isArray(entries)) return []; + const seen = new Set(); + const normalized: string[] = []; + for (const entry of entries) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +function mergePathPrepend(existing: string | undefined, prepend: string[]) { + if (prepend.length === 0) return existing; + const partsExisting = (existing ?? "") + .split(path.delimiter) + .map((part) => part.trim()) + .filter(Boolean); + const merged: string[] = []; + const seen = new Set(); + for (const part of [...prepend, ...partsExisting]) { + if (seen.has(part)) continue; + seen.add(part); + merged.push(part); + } + return merged.join(path.delimiter); +} + +function applyPathPrepend( + env: Record, + prepend: string[], + options?: { requireExisting?: boolean }, +) { + if (prepend.length === 0) return; + if (options?.requireExisting && !env.PATH) return; + const merged = mergePathPrepend(env.PATH, prepend); + 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(); @@ -240,6 +283,7 @@ export function createExecTool( typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 ? defaults.timeoutSec : 1800; + const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend); const notifyOnExit = defaults?.notifyOnExit !== false; const notifySessionKey = defaults?.sessionKey?.trim() || undefined; @@ -379,6 +423,7 @@ export function createExecTool( containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, }) : mergedEnv; + applyPathPrepend(env, defaultPathPrepend); if (host === "node") { if (security === "deny") { @@ -417,6 +462,10 @@ export function createExecTool( ); } const argv = buildNodeShellCommand(params.command, nodeInfo?.platform); + const nodeEnv = params.env ? { ...params.env } : undefined; + if (nodeEnv) { + applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); + } const invokeParams: Record = { nodeId, command: "system.run", @@ -424,7 +473,7 @@ export function createExecTool( command: argv, rawCommand: params.command, cwd: workdir, - env: params.env, + env: nodeEnv, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, agentId: defaults?.agentId, sessionKey: defaults?.sessionKey, diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 457162ae3..500b95f30 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; @@ -275,6 +277,34 @@ describe("exec notifyOnExit", () => { }); }); +describe("exec PATH handling", () => { + const originalPath = process.env.PATH; + const originalShell = process.env.SHELL; + + beforeEach(() => { + if (!isWin) process.env.SHELL = "/bin/bash"; + }); + + afterEach(() => { + process.env.PATH = originalPath; + if (!isWin) process.env.SHELL = originalShell; + }); + + it("prepends configured path entries", async () => { + const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin"; + const prepend = isWin ? ["C:\\custom\\bin", "C:\\oss\\bin"] : ["/custom/bin", "/opt/oss/bin"]; + process.env.PATH = basePath; + + const tool = createExecTool({ pathPrepend: prepend }); + const result = await tool.execute("call1", { + command: isWin ? "Write-Output $env:PATH" : "echo $PATH", + }); + + const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + expect(text).toBe([...prepend, basePath].join(path.delimiter)); + }); +}); + describe("buildDockerExecArgs", () => { it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => { const args = buildDockerExecArgs({ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index d7d0a0c11..cbf75461d 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -81,6 +81,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) { security: globalExec?.security, ask: globalExec?.ask, node: globalExec?.node, + pathPrepend: globalExec?.pathPrepend, backgroundMs: globalExec?.backgroundMs, timeoutSec: globalExec?.timeoutSec, cleanupMs: globalExec?.cleanupMs, @@ -207,6 +208,7 @@ export function createClawdbotCodingTools(options?: { security: options?.exec?.security ?? execConfig.security, ask: options?.exec?.ask ?? execConfig.ask, node: options?.exec?.node ?? execConfig.node, + pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, agentId, cwd: options?.workspaceDir, allowBackground, diff --git a/src/config/normalize-paths.test.ts b/src/config/normalize-paths.test.ts index 7323ab272..3ec077c09 100644 --- a/src/config/normalize-paths.test.ts +++ b/src/config/normalize-paths.test.ts @@ -11,6 +11,7 @@ describe("normalizeConfigPaths", () => { const { normalizeConfigPaths } = await import("./normalize-paths.js"); const cfg = normalizeConfigPaths({ + tools: { exec: { pathPrepend: ["~/bin"] } }, plugins: { load: { paths: ["~/plugins/a"] } }, logging: { file: "~/.clawdbot/logs/clawdbot.log" }, hooks: { @@ -49,6 +50,7 @@ describe("normalizeConfigPaths", () => { expect(cfg.logging?.file).toBe(path.join(home, ".clawdbot", "logs", "clawdbot.log")); expect(cfg.hooks?.path).toBe(path.join(home, ".clawdbot", "hooks.json5")); expect(cfg.hooks?.transformsDir).toBe(path.join(home, "hooks-xform")); + expect(cfg.tools?.exec?.pathPrepend?.[0]).toBe(path.join(home, "bin")); expect(cfg.channels?.telegram?.accounts?.personal?.tokenFile).toBe( path.join(home, ".clawdbot", "telegram.token"), ); diff --git a/src/config/normalize-paths.ts b/src/config/normalize-paths.ts index ab47bb61c..2e0d07298 100644 --- a/src/config/normalize-paths.ts +++ b/src/config/normalize-paths.ts @@ -4,7 +4,7 @@ import type { ClawdbotConfig } from "./types.js"; const PATH_VALUE_RE = /^~(?=$|[\\/])/; const PATH_KEY_RE = /(dir|path|paths|file|root|workspace)$/i; -const PATH_LIST_KEYS = new Set(["paths"]); +const PATH_LIST_KEYS = new Set(["paths", "pathPrepend"]); function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); diff --git a/src/config/schema.ts b/src/config/schema.ts index f61713fce..c35a59bad 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -146,6 +146,7 @@ const FIELD_LABELS: Record = { "tools.exec.security": "Exec Security", "tools.exec.ask": "Exec Ask", "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", @@ -323,6 +324,8 @@ const FIELD_HELP: Record = { 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', "tools.exec.notifyOnExit": "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "tools.exec.pathPrepend": + "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 8c4ac79d7..2288a88f2 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -129,6 +129,8 @@ export type ExecToolConfig = { ask?: "off" | "on-miss" | "always"; /** Default node binding for exec.host=node (node id/name). */ node?: string; + /** Directories to prepend to PATH when running exec (gateway/sandbox). */ + pathPrepend?: string[]; /** Default time (ms) before an exec command auto-backgrounds. */ backgroundMs?: number; /** Default timeout (seconds) before auto-killing exec commands. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2a683e443..2175e41fc 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -183,6 +183,25 @@ export const AgentToolsSchema = z allowFrom: ElevatedAllowFromSchema, }) .optional(), + exec: z + .object({ + host: z.enum(["sandbox", "gateway", "node"]).optional(), + security: z.enum(["deny", "allowlist", "full"]).optional(), + ask: z.enum(["off", "on-miss", "always"]).optional(), + node: z.string().optional(), + pathPrepend: z.array(z.string()).optional(), + backgroundMs: z.number().int().positive().optional(), + timeoutSec: z.number().int().positive().optional(), + cleanupMs: z.number().int().positive().optional(), + notifyOnExit: z.boolean().optional(), + applyPatch: z + .object({ + enabled: z.boolean().optional(), + allowModels: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(), sandbox: z .object({ tools: ToolPolicySchema, @@ -362,6 +381,7 @@ export const ToolsSchema = z security: z.enum(["deny", "allowlist", "full"]).optional(), ask: z.enum(["off", "on-miss", "always"]).optional(), node: z.string().optional(), + pathPrepend: z.array(z.string()).optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(),