feat: add exec pathPrepend config
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user