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.
|
- 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.
|
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||||
|
- Exec: add `tools.exec.pathPrepend` for prepending PATH entries on exec runs.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
- 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.security` (default: `deny`)
|
||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `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):
|
Per-agent node binding (use the agent list index in config):
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export type ExecToolDefaults = {
|
|||||||
security?: ExecSecurity;
|
security?: ExecSecurity;
|
||||||
ask?: ExecAsk;
|
ask?: ExecAsk;
|
||||||
node?: string;
|
node?: string;
|
||||||
|
pathPrepend?: string[];
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
@@ -207,6 +209,47 @@ function normalizeNotifyOutput(value: string) {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
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") {
|
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
|
||||||
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
|
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
|
||||||
const sessionKey = session.sessionKey?.trim();
|
const sessionKey = session.sessionKey?.trim();
|
||||||
@@ -240,6 +283,7 @@ export function createExecTool(
|
|||||||
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
|
||||||
? defaults.timeoutSec
|
? defaults.timeoutSec
|
||||||
: 1800;
|
: 1800;
|
||||||
|
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
|
||||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||||
|
|
||||||
@@ -379,6 +423,7 @@ export function createExecTool(
|
|||||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
||||||
})
|
})
|
||||||
: mergedEnv;
|
: mergedEnv;
|
||||||
|
applyPathPrepend(env, defaultPathPrepend);
|
||||||
|
|
||||||
if (host === "node") {
|
if (host === "node") {
|
||||||
if (security === "deny") {
|
if (security === "deny") {
|
||||||
@@ -417,6 +462,10 @@ export function createExecTool(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
|
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> = {
|
const invokeParams: Record<string, unknown> = {
|
||||||
nodeId,
|
nodeId,
|
||||||
command: "system.run",
|
command: "system.run",
|
||||||
@@ -424,7 +473,7 @@ export function createExecTool(
|
|||||||
command: argv,
|
command: argv,
|
||||||
rawCommand: params.command,
|
rawCommand: params.command,
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
env: params.env,
|
env: nodeEnv,
|
||||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||||
agentId: defaults?.agentId,
|
agentId: defaults?.agentId,
|
||||||
sessionKey: defaults?.sessionKey,
|
sessionKey: defaults?.sessionKey,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||||
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.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", () => {
|
describe("buildDockerExecArgs", () => {
|
||||||
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
|
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
|
||||||
const args = buildDockerExecArgs({
|
const args = buildDockerExecArgs({
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
|||||||
security: globalExec?.security,
|
security: globalExec?.security,
|
||||||
ask: globalExec?.ask,
|
ask: globalExec?.ask,
|
||||||
node: globalExec?.node,
|
node: globalExec?.node,
|
||||||
|
pathPrepend: globalExec?.pathPrepend,
|
||||||
backgroundMs: globalExec?.backgroundMs,
|
backgroundMs: globalExec?.backgroundMs,
|
||||||
timeoutSec: globalExec?.timeoutSec,
|
timeoutSec: globalExec?.timeoutSec,
|
||||||
cleanupMs: globalExec?.cleanupMs,
|
cleanupMs: globalExec?.cleanupMs,
|
||||||
@@ -207,6 +208,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
security: options?.exec?.security ?? execConfig.security,
|
security: options?.exec?.security ?? execConfig.security,
|
||||||
ask: options?.exec?.ask ?? execConfig.ask,
|
ask: options?.exec?.ask ?? execConfig.ask,
|
||||||
node: options?.exec?.node ?? execConfig.node,
|
node: options?.exec?.node ?? execConfig.node,
|
||||||
|
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||||
agentId,
|
agentId,
|
||||||
cwd: options?.workspaceDir,
|
cwd: options?.workspaceDir,
|
||||||
allowBackground,
|
allowBackground,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe("normalizeConfigPaths", () => {
|
|||||||
const { normalizeConfigPaths } = await import("./normalize-paths.js");
|
const { normalizeConfigPaths } = await import("./normalize-paths.js");
|
||||||
|
|
||||||
const cfg = normalizeConfigPaths({
|
const cfg = normalizeConfigPaths({
|
||||||
|
tools: { exec: { pathPrepend: ["~/bin"] } },
|
||||||
plugins: { load: { paths: ["~/plugins/a"] } },
|
plugins: { load: { paths: ["~/plugins/a"] } },
|
||||||
logging: { file: "~/.clawdbot/logs/clawdbot.log" },
|
logging: { file: "~/.clawdbot/logs/clawdbot.log" },
|
||||||
hooks: {
|
hooks: {
|
||||||
@@ -49,6 +50,7 @@ describe("normalizeConfigPaths", () => {
|
|||||||
expect(cfg.logging?.file).toBe(path.join(home, ".clawdbot", "logs", "clawdbot.log"));
|
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?.path).toBe(path.join(home, ".clawdbot", "hooks.json5"));
|
||||||
expect(cfg.hooks?.transformsDir).toBe(path.join(home, "hooks-xform"));
|
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(
|
expect(cfg.channels?.telegram?.accounts?.personal?.tokenFile).toBe(
|
||||||
path.join(home, ".clawdbot", "telegram.token"),
|
path.join(home, ".clawdbot", "telegram.token"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ClawdbotConfig } from "./types.js";
|
|||||||
const PATH_VALUE_RE = /^~(?=$|[\\/])/;
|
const PATH_VALUE_RE = /^~(?=$|[\\/])/;
|
||||||
|
|
||||||
const PATH_KEY_RE = /(dir|path|paths|file|root|workspace)$/i;
|
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> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
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.security": "Exec Security",
|
||||||
"tools.exec.ask": "Exec Ask",
|
"tools.exec.ask": "Exec Ask",
|
||||||
"tools.exec.node": "Exec Node Binding",
|
"tools.exec.node": "Exec Node Binding",
|
||||||
|
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
"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").',
|
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
|
||||||
"tools.exec.notifyOnExit":
|
"tools.exec.notifyOnExit":
|
||||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
"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":
|
"tools.message.allowCrossContextSend":
|
||||||
"Legacy override: allow cross-context sends across all providers.",
|
"Legacy override: allow cross-context sends across all providers.",
|
||||||
"tools.message.crossContext.allowWithinProvider":
|
"tools.message.crossContext.allowWithinProvider":
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export type ExecToolConfig = {
|
|||||||
ask?: "off" | "on-miss" | "always";
|
ask?: "off" | "on-miss" | "always";
|
||||||
/** Default node binding for exec.host=node (node id/name). */
|
/** Default node binding for exec.host=node (node id/name). */
|
||||||
node?: string;
|
node?: string;
|
||||||
|
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
||||||
|
pathPrepend?: string[];
|
||||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||||
|
|||||||
@@ -183,6 +183,25 @@ export const AgentToolsSchema = z
|
|||||||
allowFrom: ElevatedAllowFromSchema,
|
allowFrom: ElevatedAllowFromSchema,
|
||||||
})
|
})
|
||||||
.optional(),
|
.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
|
sandbox: z
|
||||||
.object({
|
.object({
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
@@ -362,6 +381,7 @@ export const ToolsSchema = z
|
|||||||
security: z.enum(["deny", "allowlist", "full"]).optional(),
|
security: z.enum(["deny", "allowlist", "full"]).optional(),
|
||||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||||
node: z.string().optional(),
|
node: z.string().optional(),
|
||||||
|
pathPrepend: z.array(z.string()).optional(),
|
||||||
backgroundMs: z.number().int().positive().optional(),
|
backgroundMs: z.number().int().positive().optional(),
|
||||||
timeoutSec: z.number().int().positive().optional(),
|
timeoutSec: z.number().int().positive().optional(),
|
||||||
cleanupMs: z.number().int().positive().optional(),
|
cleanupMs: z.number().int().positive().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user