feat: add exec pathPrepend config

This commit is contained in:
Peter Steinberger
2026-01-19 00:35:39 +00:00
parent d9384785a3
commit 953472bf25
10 changed files with 133 additions and 2 deletions

View File

@@ -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.

View File

@@ -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):

View File

@@ -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<string>();
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<string>();
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<string, string>,
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<string, unknown> = {
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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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"),
);

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);

View File

@@ -146,6 +146,7 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
'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":

View File

@@ -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. */

View File

@@ -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(),