diff --git a/.changeset/fuzzy-homes-read.md b/.changeset/fuzzy-homes-read.md new file mode 100644 index 000000000..9f3d2bae7 --- /dev/null +++ b/.changeset/fuzzy-homes-read.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Resolve leading `$HOME` paths in the built-in `read_file`, `write_file`, `glob`, and `grep` tools so agents can directly access packaged skill references advertised in their prompt. diff --git a/docs/concepts/default-harness.md b/docs/concepts/default-harness.md index 5c0e46ae3..847e7d43e 100644 --- a/docs/concepts/default-harness.md +++ b/docs/concepts/default-harness.md @@ -41,6 +41,8 @@ The shell and file tools (`bash`, `read_file`, `write_file`, `glob`, `grep`) run | `load_skill` | Pull an on-demand [skill](../skills)'s instructions into the current turn. Present only when the agent declares skills. | App runtime | | `connection_search` | Discover tools across declared [connections](../connections); matched tools become directly callable. Present only when the agent declares connections. | App runtime | +The model-facing file tools accept absolute paths and paths beginning with `$HOME/`. eve resolves `$HOME` against the sandbox before invoking non-shell file operations, so packaged skill references such as `$HOME/.agents/skills//references/...` work consistently across `read_file`, `write_file`, `glob`, and `grep`. + Notes: - **`agent`** is available only in the root session. Its child uses the root's instructions, tools, connections, and sandbox, but starts with fresh conversation history and fresh [state](../guides/state). The child receives neither `agent` nor `Workflow`; declared subagents do not receive the built-in `agent` either. See [Subagents](../subagents). diff --git a/docs/sandbox.mdx b/docs/sandbox.mdx index 83f4f8643..9c28f05fb 100644 --- a/docs/sandbox.mdx +++ b/docs/sandbox.mdx @@ -20,7 +20,7 @@ The model already has shell and file access through the default tools: | `glob` | find files by pattern | | `grep` | search file contents | -All of them run with `/workspace` as the working directory. Any authored runtime function (a tool, a step, a model callback) can get a live sandbox handle with `ctx.getSandbox()`. +All of them run with `/workspace` as the working directory. The model-facing file tools accept both absolute paths and paths beginning with `$HOME/`; eve resolves the latter inside the sandbox before reading, writing, or searching. Any authored runtime function (a tool, a step, a model callback) can get a live sandbox handle with `ctx.getSandbox()`. ```ts title="agent/tools/run_analysis.ts" import { defineTool } from "eve/tools"; diff --git a/packages/eve/src/execution/sandbox/glob-tool.ts b/packages/eve/src/execution/sandbox/glob-tool.ts index dfb02a22a..ca7ed5704 100644 --- a/packages/eve/src/execution/sandbox/glob-tool.ts +++ b/packages/eve/src/execution/sandbox/glob-tool.ts @@ -1,5 +1,5 @@ import { normalizeModelPath } from "#runtime/framework-tools/file-state.js"; -import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js"; import { shellQuote } from "#execution/sandbox/shell-quote.js"; @@ -49,9 +49,8 @@ export async function executeGlobOnSandbox( ): Promise { const effectivePath = args.path ?? DEFAULT_PATH; - validateAbsoluteFilePath(effectivePath); - - const normalizedPath = normalizeModelPath(effectivePath); + const resolvedPath = await resolveAbsoluteFilePath(sandbox, effectivePath); + const normalizedPath = normalizeModelPath(resolvedPath); const effectiveLimit = Math.min(Math.max(1, args.limit ?? DEFAULT_GLOB_LIMIT), MAX_GLOB_LIMIT); const command = (await ripgrepIsAvailable(sandbox)) diff --git a/packages/eve/src/execution/sandbox/grep-tool.ts b/packages/eve/src/execution/sandbox/grep-tool.ts index 4b123ce65..334ece417 100644 --- a/packages/eve/src/execution/sandbox/grep-tool.ts +++ b/packages/eve/src/execution/sandbox/grep-tool.ts @@ -1,5 +1,5 @@ import { normalizeModelPath } from "#runtime/framework-tools/file-state.js"; -import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js"; import { shellQuote } from "#execution/sandbox/shell-quote.js"; @@ -53,9 +53,8 @@ export async function executeGrepOnSandbox( ): Promise { const effectivePath = args.path ?? DEFAULT_PATH; - validateAbsoluteFilePath(effectivePath); - - const normalizedPath = normalizeModelPath(effectivePath); + const resolvedPath = await resolveAbsoluteFilePath(sandbox, effectivePath); + const normalizedPath = normalizeModelPath(resolvedPath); const effectiveLimit = Math.min(Math.max(1, args.limit ?? DEFAULT_GREP_LIMIT), MAX_GREP_LIMIT); const contextLines = args.context !== undefined && args.context > 0 ? args.context : 0; diff --git a/packages/eve/src/execution/sandbox/read-file-tool.ts b/packages/eve/src/execution/sandbox/read-file-tool.ts index 3485c5585..34868f288 100644 --- a/packages/eve/src/execution/sandbox/read-file-tool.ts +++ b/packages/eve/src/execution/sandbox/read-file-tool.ts @@ -5,7 +5,7 @@ import { normalizeModelPath, setReadFileStamp, } from "#runtime/framework-tools/file-state.js"; -import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { capLineLength, MAX_OUTPUT_BYTES } from "#execution/sandbox/truncate-output.js"; @@ -58,8 +58,8 @@ export async function executeReadFileOnSandbox( ): Promise { const { filePath, offset, limit } = args; - validateAbsoluteFilePath(filePath); - const normalizedPath = normalizeModelPath(filePath); + const resolvedPath = await resolveAbsoluteFilePath(sandbox, filePath); + const normalizedPath = normalizeModelPath(resolvedPath); // ── Validate offset / limit ───────────────────────────────────────── const effectiveOffset = offset ?? DEFAULT_OFFSET; @@ -70,7 +70,7 @@ export async function executeReadFileOnSandbox( } // ── Read full file for fingerprinting ─────────────────────────────── - const rawContent = await sandbox.readTextFile({ path: filePath }); + const rawContent = await sandbox.readTextFile({ path: resolvedPath }); if (rawContent === null) { throw new Error( diff --git a/packages/eve/src/execution/sandbox/require-sandbox.ts b/packages/eve/src/execution/sandbox/require-sandbox.ts index 8925002c3..10698fa47 100644 --- a/packages/eve/src/execution/sandbox/require-sandbox.ts +++ b/packages/eve/src/execution/sandbox/require-sandbox.ts @@ -2,6 +2,7 @@ import { loadContext } from "#context/container.js"; import { SandboxKey } from "#context/keys.js"; import type { SandboxSession } from "#public/definitions/sandbox.js"; import { bindSandboxAbortSignal } from "#execution/sandbox/abort-bound-session.js"; +import { resolveSandboxModelPath } from "#shared/skill-paths.js"; /** * Resolves the active sandbox session from the runtime context. @@ -33,14 +34,21 @@ export async function requireSandboxSession(abortSignal?: AbortSignal): Promise< } /** - * Validates that a model-supplied file path is absolute. Throws a - * descriptive error when the path does not start with `/`. + * Resolves a model-supplied `$HOME` prefix and validates that the resulting + * sandbox file path is absolute. */ -export function validateAbsoluteFilePath(filePath: string): void { - if (!filePath.startsWith("/")) { +export async function resolveAbsoluteFilePath( + sandbox: SandboxSession, + filePath: string, +): Promise { + const resolvedPath = await resolveSandboxModelPath({ path: filePath, sandbox }); + + if (!resolvedPath.startsWith("/")) { throw new Error( `filePath must be an absolute path. Received: "${filePath}". ` + - "Use an absolute path such as /workspace/foo.ts.", + "Use an absolute path such as /workspace/foo.ts or a path beginning with $HOME/.", ); } + + return resolvedPath; } diff --git a/packages/eve/src/execution/sandbox/write-file-tool.ts b/packages/eve/src/execution/sandbox/write-file-tool.ts index 439d7d915..e3727cdbc 100644 --- a/packages/eve/src/execution/sandbox/write-file-tool.ts +++ b/packages/eve/src/execution/sandbox/write-file-tool.ts @@ -7,7 +7,7 @@ import { ReadFileStateKey, setReadFileStamp, } from "#runtime/framework-tools/file-state.js"; -import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; // --------------------------------------------------------------------------- @@ -47,9 +47,9 @@ export async function executeWriteFileOnSandbox( ): Promise { const { filePath, content } = args; - validateAbsoluteFilePath(filePath); + const resolvedPath = await resolveAbsoluteFilePath(sandbox, filePath); const ctx = loadContext(); - const normalizedPath = normalizeModelPath(filePath); + const normalizedPath = normalizeModelPath(resolvedPath); const targetKey = buildReadFileTargetKey(normalizedPath); // ── Read current file ─────────────────────────────────────────────── @@ -58,11 +58,11 @@ export async function executeWriteFileOnSandbox( // cost: the entire file is read and hashed before every write. A // separate `exists()` primitive would avoid this for new files but // would require a sandbox session API change. - const currentContent = await sandbox.readTextFile({ path: filePath }); + const currentContent = await sandbox.readTextFile({ path: resolvedPath }); if (currentContent === null) { // ── File does not exist — write immediately, no prior read needed ── - await sandbox.writeTextFile({ content, path: filePath }); + await sandbox.writeTextFile({ content, path: resolvedPath }); const freshStamp = createReadFileStamp({ content, @@ -101,7 +101,7 @@ export async function executeWriteFileOnSandbox( } // ── Write and refresh stamp ───────────────────────────────────────── - await sandbox.writeTextFile({ content, path: filePath }); + await sandbox.writeTextFile({ content, path: resolvedPath }); const freshStamp = createReadFileStamp({ content, diff --git a/packages/eve/src/runtime/framework-tools/glob.ts b/packages/eve/src/runtime/framework-tools/glob.ts index 1c0e18175..e8d47e030 100644 --- a/packages/eve/src/runtime/framework-tools/glob.ts +++ b/packages/eve/src/runtime/framework-tools/glob.ts @@ -25,7 +25,7 @@ export const GLOB_INPUT_SCHEMA = z.strictObject({ .string() .describe( "The directory to search in. Defaults to /workspace. " + - "Must be an absolute path. Omit to use the default.", + "Must be an absolute path or begin with $HOME/. Omit to use the default.", ) .optional(), pattern: z diff --git a/packages/eve/src/runtime/framework-tools/grep.ts b/packages/eve/src/runtime/framework-tools/grep.ts index ce987a6c3..24dc40ad6 100644 --- a/packages/eve/src/runtime/framework-tools/grep.ts +++ b/packages/eve/src/runtime/framework-tools/grep.ts @@ -44,7 +44,7 @@ export const GREP_INPUT_SCHEMA = z.strictObject({ .string() .describe( "The directory or file to search in. Defaults to /workspace. " + - "Must be an absolute path. Omit to use the default.", + "Must be an absolute path or begin with $HOME/. Omit to use the default.", ) .optional(), pattern: z diff --git a/packages/eve/src/runtime/framework-tools/read-file.ts b/packages/eve/src/runtime/framework-tools/read-file.ts index 1a3aef167..783f87a5e 100644 --- a/packages/eve/src/runtime/framework-tools/read-file.ts +++ b/packages/eve/src/runtime/framework-tools/read-file.ts @@ -14,7 +14,9 @@ import type { ToolExecuteOptions } from "#shared/tool-definition.js"; * model input contracts in sync without duplication. */ export const READ_FILE_INPUT_SCHEMA = z.strictObject({ - filePath: z.string().describe("The absolute path to the file to read."), + filePath: z + .string() + .describe("The absolute path to the file to read. A leading $HOME is supported."), limit: z .number() .int() @@ -56,7 +58,7 @@ export const READ_FILE_TOOL_DEFINITION: ResolvedToolDefinition = { "Read a file from the local filesystem. If the path does not exist, an error is returned.", "", "Usage:", - "- The filePath parameter should be an absolute path.", + "- The filePath parameter should be an absolute path or begin with $HOME/.", "- By default, this tool returns up to 2000 lines from the start of the file.", "- The offset parameter is the line number to start from (1-indexed).", "- To read later sections, call this tool again with a larger offset.", diff --git a/packages/eve/src/runtime/framework-tools/write-file.ts b/packages/eve/src/runtime/framework-tools/write-file.ts index e2e12506d..313a18888 100644 --- a/packages/eve/src/runtime/framework-tools/write-file.ts +++ b/packages/eve/src/runtime/framework-tools/write-file.ts @@ -20,7 +20,7 @@ export const WRITE_FILE_INPUT_SCHEMA = z.strictObject({ content: z.string().describe("Complete replacement file contents."), filePath: z .string() - .describe("The absolute path to the file to write (must be absolute, not relative)."), + .describe("The absolute path to the file to write. A leading $HOME is supported."), }); /** diff --git a/packages/eve/src/shared/skill-paths.test.ts b/packages/eve/src/shared/skill-paths.test.ts index cea8261c7..c4a60ece6 100644 --- a/packages/eve/src/shared/skill-paths.test.ts +++ b/packages/eve/src/shared/skill-paths.test.ts @@ -6,6 +6,7 @@ import { MODEL_SKILL_ROOT, formatFallbackSkillPath, formatSkillModelPath, + resolveSandboxModelPath, resolveSandboxSeedFilePath, resolveSandboxSkillReadPaths, resolveSandboxSkillRoot, @@ -39,6 +40,34 @@ describe("skill path helpers", () => { expect(sandbox.commandLog).toEqual([HOME_PROBE_COMMAND]); }); + it("expands a leading $HOME in model-supplied paths", async () => { + const sandbox = mockSandbox({ + commands: { + [HOME_PROBE_COMMAND]: { exitCode: 0, stderr: "", stdout: "/home/agent/\n" }, + }, + }); + + await expect( + resolveSandboxModelPath({ path: "$HOME/notes/todo.md", sandbox: sandbox.session }), + ).resolves.toBe("/home/agent/notes/todo.md"); + await expect( + resolveSandboxModelPath({ path: "$HOME", sandbox: sandbox.session }), + ).resolves.toBe("/home/agent"); + expect(sandbox.commandLog).toEqual([HOME_PROBE_COMMAND]); + }); + + it("does not treat lookalike variables or embedded $HOME as home paths", async () => { + const sandbox = mockSandbox(); + + await expect( + resolveSandboxModelPath({ path: "$HOME_DIR/notes.md", sandbox: sandbox.session }), + ).resolves.toBe("$HOME_DIR/notes.md"); + await expect( + resolveSandboxModelPath({ path: "/workspace/$HOME/notes.md", sandbox: sandbox.session }), + ).resolves.toBe("/workspace/$HOME/notes.md"); + expect(sandbox.commandLog).toEqual([]); + }); + it("falls back to /workspace/skills when HOME is unusable", async () => { const sandbox = mockSandbox({ commands: { @@ -49,6 +78,12 @@ describe("skill path helpers", () => { await expect(resolveSandboxSkillRoot({ sandbox: sandbox.session })).resolves.toBe( FALLBACK_SKILL_ROOT, ); + await expect( + resolveSandboxModelPath({ + path: `${MODEL_SKILL_ROOT}/research/references/catalog.md`, + sandbox: sandbox.session, + }), + ).resolves.toBe("/workspace/skills/research/references/catalog.md"); }); it("reads only from the resolved HOME skill root when HOME is usable", async () => { diff --git a/packages/eve/src/shared/skill-paths.ts b/packages/eve/src/shared/skill-paths.ts index fd5b91fc1..04faddd35 100644 --- a/packages/eve/src/shared/skill-paths.ts +++ b/packages/eve/src/shared/skill-paths.ts @@ -3,9 +3,10 @@ import type { SandboxSession } from "#shared/sandbox-session.js"; export const MODEL_SKILL_ROOT = "$HOME/.agents/skills"; export const FALLBACK_SKILL_ROOT = "/workspace/skills"; +const MODEL_HOME_ROOT = "$HOME"; const HOME_SKILL_SUFFIX = ".agents/skills"; const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; -const skillRootCache = new WeakMap>(); +const sandboxHomeCache = new WeakMap>(); export function formatSkillModelPath(input: { readonly name: string; @@ -32,14 +33,35 @@ export function formatFallbackSkillPath(input: { export async function resolveSandboxSkillRoot(input: { readonly sandbox: SandboxSession; }): Promise { - const cached = skillRootCache.get(input.sandbox); - if (cached !== undefined) { - return await cached; + const home = await resolveSandboxHome(input.sandbox); + return home === null ? FALLBACK_SKILL_ROOT : joinHomeSkillRoot(home); +} + +/** + * Resolves a leading `$HOME` in a model-supplied sandbox path without + * evaluating any other shell syntax. Skill paths retain their documented + * `/workspace/skills` fallback when the sandbox does not expose a usable + * home directory. + */ +export async function resolveSandboxModelPath(input: { + readonly path: string; + readonly sandbox: SandboxSession; +}): Promise { + if (!isModelHomePath(input.path)) { + return input.path; } - const next = probeSandboxSkillRoot(input.sandbox); - skillRootCache.set(input.sandbox, next); - return await next; + const home = await resolveSandboxHome(input.sandbox); + if (home !== null) { + const suffix = input.path.slice(MODEL_HOME_ROOT.length); + return suffix.length === 0 ? home : `${home === "/" ? "" : home}${suffix}`; + } + + if (isModelSkillPath(input.path)) { + return `${FALLBACK_SKILL_ROOT}${input.path.slice(MODEL_SKILL_ROOT.length)}`; + } + + return input.path; } export async function resolveSandboxSkillReadPaths(input: { @@ -72,12 +94,11 @@ export async function resolveSandboxSeedFilePath(input: { readonly path: string; readonly sandbox: SandboxSession; }): Promise { - if (!input.path.startsWith(`${MODEL_SKILL_ROOT}/`)) { + if (!isModelSkillPath(input.path)) { return input.path; } - const root = await resolveSandboxSkillRoot({ sandbox: input.sandbox }); - return `${root}${input.path.slice(MODEL_SKILL_ROOT.length)}`; + return await resolveSandboxModelPath(input); } function formatSkillPath(input: { @@ -88,24 +109,43 @@ function formatSkillPath(input: { return `${input.root}/${input.name}/${input.relativePath}`; } -async function probeSandboxSkillRoot(sandbox: SandboxSession): Promise { +async function resolveSandboxHome(sandbox: SandboxSession): Promise { + const cached = sandboxHomeCache.get(sandbox); + if (cached !== undefined) { + return await cached; + } + + const next = probeSandboxHome(sandbox); + sandboxHomeCache.set(sandbox, next); + return await next; +} + +async function probeSandboxHome(sandbox: SandboxSession): Promise { try { const result = await sandbox.run({ command: HOME_PROBE_COMMAND }); if (result.exitCode !== 0) { - return FALLBACK_SKILL_ROOT; + return null; } const home = result.stdout.trim(); if (!isUsableSandboxHome(home)) { - return FALLBACK_SKILL_ROOT; + return null; } - return joinHomeSkillRoot(home); + return normalizeSandboxHome(home); } catch { - return FALLBACK_SKILL_ROOT; + return null; } } +function isModelHomePath(path: string): boolean { + return path === MODEL_HOME_ROOT || path.startsWith(`${MODEL_HOME_ROOT}/`); +} + +function isModelSkillPath(path: string): boolean { + return path === MODEL_SKILL_ROOT || path.startsWith(`${MODEL_SKILL_ROOT}/`); +} + function isUsableSandboxHome(path: string): boolean { return ( path.length > 0 && @@ -117,6 +157,9 @@ function isUsableSandboxHome(path: string): boolean { } function joinHomeSkillRoot(home: string): string { - const normalizedHome = home === "/" ? "" : home.replace(/\/+$/, ""); - return `${normalizedHome}/${HOME_SKILL_SUFFIX}`; + return `${home === "/" ? "" : home}/${HOME_SKILL_SUFFIX}`; +} + +function normalizeSandboxHome(home: string): string { + return home === "/" ? home : home.replace(/\/+$/, ""); } diff --git a/packages/eve/test/glob-tool.test.ts b/packages/eve/test/glob-tool.test.ts index 2d6baad67..a32f9c90f 100644 --- a/packages/eve/test/glob-tool.test.ts +++ b/packages/eve/test/glob-tool.test.ts @@ -24,6 +24,7 @@ interface FakeAccessOptions { } const PROBE_COMMAND = "command -v rg >/dev/null 2>&1"; +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; function createFakeAccess( commandHandler: (command: string) => { exitCode: number; stderr: string; stdout: string }, @@ -55,6 +56,9 @@ function createFakeAccess( return options.pathResolver ? options.pathResolver(path) : path; }, async run({ command }: { command: string }) { + if (command === HOME_PROBE_COMMAND) { + return { exitCode: 0, stderr: "", stdout: "/home/agent\n" }; + } if (command === PROBE_COMMAND) { return { exitCode: probeExitCode, stderr: "", stdout: "" }; } @@ -135,6 +139,28 @@ describe("executeGlobOnSandbox", () => { ).rejects.toThrow("filePath must be an absolute path"); }); + it("expands a leading $HOME before searching", async () => { + let capturedCommand = ""; + const result = (await runInContext( + (cmd) => { + capturedCommand = cmd; + return { + exitCode: 0, + stderr: "", + stdout: "/home/agent/.agents/skills/research/SKILL.md\n", + }; + }, + (sandbox) => + executeGlobOnSandbox(sandbox, { + path: "$HOME/.agents/skills/research", + pattern: "**/*.md", + }), + )) as GlobResult; + + expect(capturedCommand).toContain("'/home/agent/.agents/skills/research'"); + expect(result.path).toBe("/home/agent/.agents/skills/research"); + }); + // --------------------------------------------------------------------------- // Empty results // --------------------------------------------------------------------------- diff --git a/packages/eve/test/grep-tool.test.ts b/packages/eve/test/grep-tool.test.ts index f2abad1ad..452895d9b 100644 --- a/packages/eve/test/grep-tool.test.ts +++ b/packages/eve/test/grep-tool.test.ts @@ -24,6 +24,7 @@ interface FakeAccessOptions { } const PROBE_COMMAND = "command -v rg >/dev/null 2>&1"; +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; function createFakeAccess( commandHandler: (command: string) => { exitCode: number; stderr: string; stdout: string }, @@ -55,6 +56,9 @@ function createFakeAccess( return options.pathResolver ? options.pathResolver(path) : path; }, async run({ command }: { command: string }) { + if (command === HOME_PROBE_COMMAND) { + return { exitCode: 0, stderr: "", stdout: "/home/agent\n" }; + } if (command === PROBE_COMMAND) { return { exitCode: probeExitCode, stderr: "", stdout: "" }; } @@ -137,6 +141,28 @@ describe("executeGrepOnSandbox", () => { ).rejects.toThrow("filePath must be an absolute path"); }); + it("expands a leading $HOME before searching", async () => { + let capturedCommand = ""; + const result = (await runInContext( + (cmd) => { + capturedCommand = cmd; + return { + exitCode: 0, + stderr: "", + stdout: "/home/agent/.agents/skills/research/SKILL.md:1:Research\n", + }; + }, + (sandbox) => + executeGrepOnSandbox(sandbox, { + path: "$HOME/.agents/skills/research", + pattern: "Research", + }), + )) as GrepResult; + + expect(capturedCommand).toContain("'/home/agent/.agents/skills/research'"); + expect(result.path).toBe("/home/agent/.agents/skills/research"); + }); + // --------------------------------------------------------------------------- // Empty results // --------------------------------------------------------------------------- diff --git a/packages/eve/test/read-file-tool.test.ts b/packages/eve/test/read-file-tool.test.ts index f5bb52f0b..80ae51b65 100644 --- a/packages/eve/test/read-file-tool.test.ts +++ b/packages/eve/test/read-file-tool.test.ts @@ -10,6 +10,8 @@ import type { SandboxAccess } from "../src/sandbox/state.js"; import type { SandboxSession } from "../src/shared/sandbox-session.js"; import { ReadFileStateKey } from "../src/runtime/framework-tools/file-state.js"; +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -39,7 +41,10 @@ function createFakeAccess(files: Record): SandboxAccess { resolvePath(path: string) { return path; }, - async run(_options: { command: string }) { + async run({ command }: { command: string }) { + if (command === HOME_PROBE_COMMAND) { + return { exitCode: 0, stderr: "", stdout: "/home/agent\n" }; + } return { exitCode: 0, stderr: "", stdout: "" }; }, async spawn() { @@ -232,6 +237,18 @@ describe("executeReadFileOnSandbox", () => { ).rejects.toThrow("filePath must be an absolute path"); }); + it("reads skill files through the model-facing $HOME path", async () => { + const filePath = "/home/agent/.agents/skills/research/references/catalog.md"; + const result = (await runInContext({ [filePath]: "catalog\n" }, (sandbox) => + executeReadFileOnSandbox(sandbox, { + filePath: "$HOME/.agents/skills/research/references/catalog.md", + }), + )) as ReadFileResult; + + expect(result.content).toBe("1: catalog"); + expect(result.path).toBe(filePath); + }); + // --------------------------------------------------------------------------- // Missing file // --------------------------------------------------------------------------- diff --git a/packages/eve/test/write-file-tool.test.ts b/packages/eve/test/write-file-tool.test.ts index 2c66917b5..fa2391282 100644 --- a/packages/eve/test/write-file-tool.test.ts +++ b/packages/eve/test/write-file-tool.test.ts @@ -14,6 +14,8 @@ import { ReadFileStateKey, } from "../src/runtime/framework-tools/file-state.js"; +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -42,7 +44,10 @@ function createFakeAccess(files: Record): { resolvePath(path: string) { return path; }, - async run(_options: { command: string }) { + async run({ command }: { command: string }) { + if (command === HOME_PROBE_COMMAND) { + return { exitCode: 0, stderr: "", stdout: "/home/agent\n" }; + } return { exitCode: 0, stderr: "", stdout: "" }; }, async spawn() { @@ -301,6 +306,26 @@ describe("executeWriteFileOnSandbox", () => { ).rejects.toThrow("filePath must be an absolute path"); }); + it("shares read-before-write state with the resolved form of a $HOME path", async () => { + const filePath = "/home/agent/.agents/skills/research/references/catalog.md"; + const { access, files, session } = createFakeAccess({ [filePath]: "original" }); + + const ctx = new ContextContainer(); + ctx.set(SandboxKey, access); + ctx.set(ReadFileStateKey, { byTarget: {} }); + + const result = (await contextStorage.run(ctx, async () => { + await executeReadFileOnSandbox(session, { filePath }); + return await executeWriteFileOnSandbox(session, { + content: "updated", + filePath: "$HOME/.agents/skills/research/references/catalog.md", + }); + })) as WriteFileResult; + + expect(result).toEqual({ existed: true, path: filePath }); + expect(files[filePath]).toBe("updated"); + }); + // --------------------------------------------------------------------------- // Path canonicalization // ---------------------------------------------------------------------------