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

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