diff --git a/packages/agent-core/src/prompt.ts b/packages/agent-core/src/prompt.ts index 28dbe058..65efb126 100644 --- a/packages/agent-core/src/prompt.ts +++ b/packages/agent-core/src/prompt.ts @@ -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 ` + diff --git a/packages/agent-core/test/prompt.test.ts b/packages/agent-core/test/prompt.test.ts index 6380e0eb..bfc75c10 100644 --- a/packages/agent-core/test/prompt.test.ts +++ b/packages/agent-core/test/prompt.test.ts @@ -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"); }); diff --git a/packages/tools/src/builtins/process.ts b/packages/tools/src/builtins/process.ts index 52f8d52d..d8d54194 100644 --- a/packages/tools/src/builtins/process.ts +++ b/packages/tools/src/builtins/process.ts @@ -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 @@ -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"], }); @@ -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"; @@ -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)"), diff --git a/packages/tools/src/builtins/shell.ts b/packages/tools/src/builtins/shell.ts index 986e247a..7b9e42bc 100644 --- a/packages/tools/src/builtins/shell.ts +++ b/packages/tools/src/builtins/shell.ts @@ -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)/; @@ -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 @@ -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(); @@ -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"], }); diff --git a/packages/tools/src/internal/shell-executable.ts b/packages/tools/src/internal/shell-executable.ts new file mode 100644 index 00000000..f7dbfead --- /dev/null +++ b/packages/tools/src/internal/shell-executable.ts @@ -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; +} diff --git a/packages/tools/src/internal/shell-session.ts b/packages/tools/src/internal/shell-session.ts index 771ea3c1..cc0f82f5 100644 --- a/packages/tools/src/internal/shell-session.ts +++ b/packages/tools/src/internal/shell-session.ts @@ -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; @@ -60,7 +61,7 @@ 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; } @@ -68,17 +69,16 @@ export class ShellSession { 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(); @@ -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); @@ -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 diff --git a/packages/tools/src/tool-env.ts b/packages/tools/src/tool-env.ts new file mode 100644 index 00000000..1a66b01a --- /dev/null +++ b/packages/tools/src/tool-env.ts @@ -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), + }; +} diff --git a/packages/tools/test/process.test.ts b/packages/tools/test/process.test.ts new file mode 100644 index 00000000..deaf6e1c --- /dev/null +++ b/packages/tools/test/process.test.ts @@ -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>( + args: Record +): Promise { + 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"); + }); +}); diff --git a/packages/tools/test/shell-executable.test.ts b/packages/tools/test/shell-executable.test.ts new file mode 100644 index 00000000..56b3fcba --- /dev/null +++ b/packages/tools/test/shell-executable.test.ts @@ -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" + ); + }); +}); diff --git a/packages/tools/test/workspace-cwd.test.ts b/packages/tools/test/workspace-cwd.test.ts index b09f4602..f67065c0 100644 --- a/packages/tools/test/workspace-cwd.test.ts +++ b/packages/tools/test/workspace-cwd.test.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import { registry } from "../src/registry.js"; import { toolCallContext } from "../src/session-context.js"; import { closeAllShellSessions } from "../src/internal/shell-session.js"; +import { _resetProcessRegistry } from "../src/builtins/process.js"; // Side-effect imports — each tool self-registers at module load. import "../src/builtins/shell.js"; import "../src/builtins/file.js"; @@ -14,6 +15,7 @@ import "../src/builtins/apply-patch.js"; afterEach(() => { // Reap any per-(agent, session) bash subprocesses spawned during the test. closeAllShellSessions(); + _resetProcessRegistry(); }); // `/agents//workspace/` is the default cwd for the agent's @@ -120,6 +122,76 @@ describe("shell — cwd defaults to workspace", () => { res.output === cwd || res.output === path.join("/private", cwd) ).toBe(true); }); + + it("exposes WORKSPACE_HOME and AGENT_HOME in persistent shell sessions", async () => { + const ctx = { + sessionId: "sess-home-env", + agentId: "agent-home-env", + workspaceDir, + }; + const res = await toolCallContext.run(ctx, () => + runTool<{ success: boolean; output: string }>("shell", { + command: + 'printf \'workspace=%s\\nagent=%s\\n\' "$WORKSPACE_HOME" "$AGENT_HOME"', + timeout: 5000, + }), + ); + expect(res.success).toBe(true); + expect(res.output).toContain(`workspace=${workspaceDir}`); + expect(res.output).toContain(`agent=${path.dirname(workspaceDir)}`); + }); + + it("exposes WORKSPACE_HOME and AGENT_HOME in non-persistent shell executions", async () => { + const res = await toolCallContext.run( + { sessionId: "", agentId: "", workspaceDir }, + () => + runTool<{ success: boolean; output: string }>("shell", { + command: + 'printf \'workspace=%s\\nagent=%s\\n\' "$WORKSPACE_HOME" "$AGENT_HOME"', + timeout: 5000, + }), + ); + expect(res.success).toBe(true); + expect(res.output).toContain(`workspace=${workspaceDir}`); + expect(res.output).toContain(`agent=${path.dirname(workspaceDir)}`); + }); +}); + +describe("process — home environment", () => { + it("exposes WORKSPACE_HOME and AGENT_HOME to started processes", async () => { + const started = await withWorkspace(workspaceDir, () => + runTool<{ success: boolean; id: string }>("process", { + action: "start", + command: + 'printf \'workspace=%s\\nagent=%s\\n\' "$WORKSPACE_HOME" "$AGENT_HOME"', + timeoutMs: 5000, + silenceTimeoutMs: 5000, + }), + ); + expect(started.success).toBe(true); + + let output = ""; + let status = "running"; + for (let i = 0; i < 20 && status === "running"; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + const polled = await withWorkspace(workspaceDir, () => + runTool<{ success: boolean; status: string; output: string }>( + "process", + { + action: "poll", + id: started.id, + }, + ), + ); + expect(polled.success).toBe(true); + output += polled.output; + status = polled.status; + } + + expect(status).not.toBe("running"); + expect(output).toContain(`workspace=${workspaceDir}`); + expect(output).toContain(`agent=${path.dirname(workspaceDir)}`); + }); }); describe("write_file + read_file — relative paths resolve to workspaceDir", () => {