Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent-core/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ export function buildSystemPrompt(options: {
parts.push(
`\n## Workspace\nYour workspace directory is \`${options.workspaceDir}\`. ` +
`Shell commands, file ops, and the Python REPL default to this location. ` +
`Shell and process commands also receive \`WORKSPACE_HOME\` for this ` +
`directory and \`AGENT_HOME\` for its parent, so prefer those when ` +
`you need stable absolute paths. ` +
`Your shell maintains state across calls in this session — \`cd\`, ` +
`exported environment variables, and shell functions all persist. ` +
`Absolute paths are allowed; what you can actually read and write ` +
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/test/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ describe("Workspace section (per-agent default cwd)", () => {
});
expect(prompt).toContain("## Workspace");
expect(prompt).toContain("/data/agents/alice/workspace");
expect(prompt).toContain("WORKSPACE_HOME");
expect(prompt).toContain("AGENT_HOME");
expect(prompt).toContain("persist");
});

Expand Down
12 changes: 10 additions & 2 deletions packages/tools/src/builtins/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { registry } from "../registry.js";
import { getCurrentWorkspaceDir } from "../session-context.js";
import { buildToolHomeEnv } from "../tool-env.js";
import { resolveShellForSpawn } from "../internal/shell-executable.js";

/**
* Background process management. One tool with an action enum, mirroring
Expand Down Expand Up @@ -151,7 +153,8 @@ function startProc(args: {
const effectiveCwd = args.cwd ?? baseCwd;
const child = spawn(args.command, {
cwd: effectiveCwd,
shell: process.platform === "win32" ? true : "/bin/bash",
env: { ...process.env, ...buildToolHomeEnv() },
shell: resolveShellForSpawn(),
detached: process.platform !== "win32", // for process-group kill
stdio: ["pipe", "pipe", "pipe"],
});
Expand Down Expand Up @@ -192,6 +195,10 @@ function startProc(args: {
}
});
child.on("error", (err) => {
if (e.overallTimer) clearTimeout(e.overallTimer);
if (e.silenceTimer) clearTimeout(e.silenceTimer);
e.overallTimer = null;
e.silenceTimer = null;
appendOutput(e, `\n[spawn error: ${err.message}]\n`);
e.endedAt = Date.now();
if (e.status === "running") e.status = "killed";
Expand Down Expand Up @@ -231,7 +238,8 @@ registry.register({
"`poll` (status + new output since last poll, then clears pending buffer), " +
"`log` (full transcript), " +
"`write` (send to stdin; `data` may end with \\n), " +
"`kill` (SIGTERM then SIGKILL).",
"`kill` (SIGTERM then SIGKILL). Started commands receive `WORKSPACE_HOME` " +
"and `AGENT_HOME` environment variables for stable absolute path references.",
parameters: z.object({
action: z.enum(["start", "list", "status", "poll", "log", "write", "kill"]),
id: z.string().optional().describe("Process id (required for all actions except start/list)"),
Expand Down
11 changes: 8 additions & 3 deletions packages/tools/src/builtins/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getCurrentSessionId,
} from "../session-context.js";
import { getShellSession } from "../internal/shell-session.js";
import { buildToolHomeEnv } from "../tool-env.js";
import { resolveShellForExec } from "../internal/shell-executable.js";

const DESTRUCTIVE_PATTERNS = /(?:^|\s|&&|\|\||;|`)(?:rm\s|rmdir\s|cp\s|mv\s|sed\s+-i|truncate\s|dd\s|shred\s|git\s+(?:reset|clean|checkout)\s)/;

Expand All @@ -23,7 +25,8 @@ registry.register({
"installing packages, checking system state, and any terminal operations. " +
"Runs from your agent's workspace dir by default. State persists across " +
"calls in this session — `cd`, exported env vars, shell functions, and " +
"history are preserved.",
"history are preserved. Commands receive `WORKSPACE_HOME` and `AGENT_HOME` " +
"environment variables for stable absolute path references.",
parameters: z.object({
command: z.string().describe("The shell command to execute"),
timeout: z
Expand All @@ -49,11 +52,12 @@ registry.register({
const workspaceDir = getCurrentWorkspaceDir();
const agentId = getCurrentAgentId();
const sessionId = getCurrentSessionId();
const toolHomeEnv = buildToolHomeEnv(workspaceDir);

// Persistent path: a real bash subprocess per (agentId, sessionId)
// so `cd`, env vars, and shell functions survive across calls.
if (workspaceDir && agentId && sessionId) {
const session = getShellSession(agentId, sessionId, workspaceDir);
const session = getShellSession(agentId, sessionId, workspaceDir, toolHomeEnv);
try {
const res = await session.exec(command, timeout);
const trimmed = res.output.trimEnd();
Expand Down Expand Up @@ -97,8 +101,9 @@ registry.register({
timeout,
encoding: "utf-8",
maxBuffer: 1024 * 1024 * 10, // 10MB
shell: "/bin/bash",
shell: resolveShellForExec(),
cwd: baseCwd,
env: { ...process.env, ...toolHomeEnv },
stdio: ["pipe", "pipe", "pipe"],
});

Expand Down
67 changes: 67 additions & 0 deletions packages/tools/src/internal/shell-executable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { existsSync } from "node:fs";
import * as path from "node:path";

export type ResolvedShellKind = "bash" | "sh";

export interface ResolvedShell {
command: string;
args: string[];
kind: ResolvedShellKind;
}

type Exists = (candidate: string) => boolean;

function isAbsoluteExecutable(candidate: string | undefined, exists: Exists): string | null {
if (!candidate || !path.isAbsolute(candidate)) return null;
return exists(candidate) ? candidate : null;
}

function findOnPath(command: string, env: NodeJS.ProcessEnv, exists: Exists): string | null {
const pathValue = env.PATH;
if (!pathValue) return null;
for (const dir of pathValue.split(path.delimiter)) {
if (!dir) continue;
const candidate = path.join(dir, command);
if (exists(candidate)) return candidate;
}
return null;
}

export function resolveShell(
env: NodeJS.ProcessEnv = process.env,
exists: Exists = existsSync
): ResolvedShell {
if (process.platform === "win32") {
return { command: "cmd.exe", args: [], kind: "sh" };
}

const bash =
isAbsoluteExecutable(env.BASH, exists) ??
isAbsoluteExecutable("/bin/bash", exists) ??
findOnPath("bash", env, exists);
if (bash) {
return { command: bash, args: ["--norc", "--noprofile"], kind: "bash" };
}

const sh =
isAbsoluteExecutable("/bin/sh", exists) ??
findOnPath("sh", env, exists) ??
"sh";
return { command: sh, args: [], kind: "sh" };
}

export function resolveShellForSpawn(
env: NodeJS.ProcessEnv = process.env,
exists: Exists = existsSync
): string | boolean {
if (process.platform === "win32") return true;
return resolveShell(env, exists).command;
}

export function resolveShellForExec(
env: NodeJS.ProcessEnv = process.env,
exists: Exists = existsSync
): string | undefined {
if (process.platform === "win32") return undefined;
return resolveShell(env, exists).command;
}
21 changes: 11 additions & 10 deletions packages/tools/src/internal/shell-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resolveShell } from "./shell-executable.js";

export interface ShellExecResult {
output: string;
Expand Down Expand Up @@ -60,25 +61,24 @@ export class ShellSession {
private currentCwd: string;
lastUsedAt = Date.now();

constructor(public readonly initialCwd: string) {
constructor(public readonly initialCwd: string, private readonly baseEnv: NodeJS.ProcessEnv = {}) {
this.currentCwd = initialCwd;
}

get busy(): boolean {
return this.pending !== null;
}

/** True while a bash subprocess is attached. */
/** True while a shell subprocess is attached. */
get live(): boolean {
return this.proc !== null;
}

private spawnBash(): ChildProcessWithoutNullStreams {
// --norc / --noprofile keep bash predictable across user environments
// — no surprise aliases from a developer's ~/.bashrc bleeding in.
const proc = spawn("/bin/bash", ["--norc", "--noprofile"], {
private spawnShell(): ChildProcessWithoutNullStreams {
const shell = resolveShell();
const proc = spawn(shell.command, shell.args, {
cwd: fs.existsSync(this.currentCwd) ? this.currentCwd : this.initialCwd,
env: { ...process.env, PS1: "" },
env: { ...process.env, ...this.baseEnv, PS1: "" },
stdio: ["pipe", "pipe", "pipe"],
});
proc.unref();
Expand Down Expand Up @@ -151,7 +151,7 @@ export class ShellSession {

if (!this.proc || this.dead) {
this.dead = false;
this.proc = this.spawnBash();
this.proc = this.spawnShell();
}

const uuid = randomUUID().replace(/-/g, "").slice(0, 16);
Expand Down Expand Up @@ -281,13 +281,14 @@ function pruneTrackedIfFull(): void {
export function getShellSession(
agentId: string,
sessionId: string,
initialCwd: string
initialCwd: string,
baseEnv: NodeJS.ProcessEnv = {}
): ShellSession {
const key = `${agentId}:${sessionId}`;
let s = sessions.get(key);
if (!s) {
pruneTrackedIfFull();
s = new ShellSession(initialCwd);
s = new ShellSession(initialCwd, baseEnv);
sessions.set(key, s);
}
// The caller is about to exec — make room under the live cap and make
Expand Down
12 changes: 12 additions & 0 deletions packages/tools/src/tool-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as path from "node:path";
import { getCurrentWorkspaceDir } from "./session-context.js";

export function buildToolHomeEnv(
workspaceDir = getCurrentWorkspaceDir(),
): NodeJS.ProcessEnv {
if (!workspaceDir) return {};
return {
WORKSPACE_HOME: workspaceDir,
AGENT_HOME: path.dirname(workspaceDir),
};
}
49 changes: 49 additions & 0 deletions packages/tools/test/process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from "vitest";
import { registry } from "../src/registry.js";
import { _resetProcessRegistry } from "../src/builtins/process.js";
import "../src/builtins/process.js";

afterEach(() => {
_resetProcessRegistry();
});

async function runProcessTool<R = Record<string, unknown>>(
args: Record<string, unknown>
): Promise<R> {
const tool = registry.get("process");
if (!tool) throw new Error("process tool not registered");
return JSON.parse(await tool.handler(args)) as R;
}

async function pollUntilExited(id: string): Promise<{ output: string; status: string }> {
let output = "";
let status = "running";
for (let i = 0; i < 20; i++) {
const result = await runProcessTool<{ output: string; status: string }>({
action: "poll",
id,
});
output += result.output ?? "";
status = result.status;
if (status !== "running") return { output, status };
await new Promise((resolve) => setTimeout(resolve, 25));
}
return { output, status };
}

describe("process tool", () => {
it("starts commands through the resolved shell", async () => {
const started = await runProcessTool<{ success: boolean; id: string; status: string }>({
action: "start",
command: "printf process-ok",
timeoutMs: 5000,
silenceTimeoutMs: 5000,
});
expect(started.success).toBe(true);
expect(started.status).toBe("running");

const result = await pollUntilExited(started.id);
expect(result.status).toBe("exited");
expect(result.output).toContain("process-ok");
});
});
48 changes: 48 additions & 0 deletions packages/tools/test/shell-executable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
resolveShell,
resolveShellForExec,
resolveShellForSpawn,
} from "../src/internal/shell-executable.js";

function exists(paths: string[]) {
const set = new Set(paths);
return (candidate: string) => set.has(candidate);
}

describe("shell executable resolution", () => {
it("prefers a valid BASH env path", () => {
const shell = resolveShell(
{ BASH: "/opt/homebrew/bin/bash", PATH: "/bin" },
exists(["/opt/homebrew/bin/bash", "/bin/bash", "/bin/sh"])
);
expect(shell).toEqual({
command: "/opt/homebrew/bin/bash",
args: ["--norc", "--noprofile"],
kind: "bash",
});
});

it("ignores a missing /bin/bash and falls back to bash on PATH", () => {
const shell = resolveShell(
{ PATH: "/nix/store/bin:/bin" },
exists(["/nix/store/bin/bash", "/bin/sh"])
);
expect(shell.command).toBe("/nix/store/bin/bash");
expect(shell.kind).toBe("bash");
});

it("falls back to sh when bash is unavailable", () => {
const shell = resolveShell(
{ BASH: "/bin/bash", PATH: "/usr/bin" },
exists(["/bin/sh"])
);
expect(shell).toEqual({ command: "/bin/sh", args: [], kind: "sh" });
expect(resolveShellForSpawn({ PATH: "/usr/bin" }, exists(["/bin/sh"]))).toBe(
"/bin/sh"
);
expect(resolveShellForExec({ PATH: "/usr/bin" }, exists(["/bin/sh"]))).toBe(
"/bin/sh"
);
});
});
Loading