From ed10e40ee646ed4e8ab2472e6bc82fb4aaf51c60 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 1 Nov 2026 22:11:06 -0600 Subject: [PATCH] feat(core): add Node-style fs and path to Code Mode --- packages/core/src/codemode/fs.ts | 354 ++++++++++++++++++++++ packages/core/src/codemode/path.ts | 9 + packages/core/src/codemode/tool.ts | 22 +- packages/core/src/file-write.ts | 8 +- packages/core/src/tool.ts | 17 +- packages/core/test/mcp.test.ts | 6 +- packages/core/test/tool-execute.test.ts | 145 ++++++++- packages/core/test/tool-question.test.ts | 3 + packages/core/test/tool-registry.test.ts | 3 + packages/core/test/tool-schedule.test.ts | 8 +- packages/core/test/tool-skill.test.ts | 7 + packages/core/test/tool-todo.test.ts | 8 +- packages/core/test/tool-webfetch.test.ts | 3 + packages/core/test/tool-websearch.test.ts | 3 + 14 files changed, 576 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/codemode/fs.ts create mode 100644 packages/core/src/codemode/path.ts diff --git a/packages/core/src/codemode/fs.ts b/packages/core/src/codemode/fs.ts new file mode 100644 index 00000000000..ce7d9aa36e5 --- /dev/null +++ b/packages/core/src/codemode/fs.ts @@ -0,0 +1,354 @@ +export * as CodeModeFs from "./fs.js" + +import { Extension } from "@opencode/codemode" +import type { Context as ToolContext } from "@opencode/schema/tool" +import { Effect } from "effect" +import path from "path" +import { fileURLToPath } from "url" +import { Environment } from "../environment/index.js" +import { FileAccess } from "../file-access.js" +import { FileMutation } from "../file-mutation.js" +import { FileWrite } from "../file-write.js" +import { Permission } from "../permission.js" +import { CodeModeExecution } from "./execution.js" + +/** The services the `fs` global needs from the execute call's Location. */ +export type Services = FileAccess.Service | FileWrite.Service | FileMutation.Service | Environment.Service + +type PathLike = string | URL +type EncodingOption = BufferEncoding | { readonly encoding?: BufferEncoding | null } | null | undefined +type Data = string | Uint8Array + +// Reads ask the read permission and writes go through FileWrite, so the rules, prompts, diffs and formatting match the +// read and write tools. Errors carry Node's `code`, `syscall` and `path`, so `e.code === "ENOENT"` works. +const readFile = (file: PathLike, options?: EncodingOption) => + run("open", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.access.authorizeRead(file, host.execution) + const bytes = (yield* host.files.read(target.absolute)).bytes + const encoding = encodingOf(options) + return encoding ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString(encoding) : bytes + }), + ) + +const writeFile = (file: PathLike, data: Data, options?: EncodingOption) => + run("open", file, (host, file) => write(host, file, bytesOrText(data, options))) + +const appendFile = (file: PathLike, data: Data, options?: EncodingOption) => + run("open", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.access.resolve({ path: file, kind: "file" }) + const current = yield* host.files.read(target.absolute).pipe( + Effect.map((result) => result.bytes), + Effect.catchTag("Environment.NotFound", () => Effect.succeed(new Uint8Array())), + ) + const added = bytesOrText(data, options) + yield* write(host, file, Buffer.concat([current, typeof added === "string" ? Buffer.from(added) : added])) + }), + ) + +const readdir = ( + file: PathLike, + options?: { readonly withFileTypes?: boolean; readonly recursive?: boolean } | BufferEncoding | null, +) => + run("scandir", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.access.authorizeRead(file, host.execution) + const recursive = typeof options === "object" && options?.recursive === true + const entries = yield* walk(host.files, target.absolute, recursive) + if (typeof options !== "object" || options?.withFileTypes !== true) return entries.map((entry) => entry.relative) + return entries.map((entry) => ({ + name: path.basename(entry.relative), + parentPath: path.join(file, path.dirname(entry.relative)), + ...kind(entry.type), + })) + }), + ) + +const stat = (file: PathLike) => run("stat", file, (host, file) => statOf(host, file, true)) + +const lstat = (file: PathLike) => run("lstat", file, (host, file) => statOf(host, file, false)) + +const access = (file: PathLike, _mode?: number) => + run("access", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.access.authorizeRead(file, host.execution) + yield* Environment.typeFollowing(host.files, target.absolute) + }), + ) + +const existsSync = (file: PathLike) => + run("access", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.access.authorizeRead(file, host.execution) + return yield* Environment.exists(host.files, target.absolute) + }), + ) + +const mkdir = (file: PathLike, options?: { readonly recursive?: boolean } | number) => + run("mkdir", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.fileWrite.target(file, host.execution) + yield* host.fileWrite.authorize(target, host.execution) + if (typeof options !== "object" || options.recursive !== true) { + if (yield* Environment.exists(host.files, target.absolute)) return yield* systemError("EEXIST", "mkdir", file) + if (!(yield* Environment.exists(host.files, path.dirname(target.absolute)))) + return yield* systemError("ENOENT", "mkdir", file) + } + yield* host.files.mkdir(target.absolute) + }), + ) + +const rm = (file: PathLike, options?: { readonly recursive?: boolean; readonly force?: boolean }) => + run("rm", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.fileWrite.target(file, host.execution) + const info = yield* optionalStat(host, target.absolute) + if (info === undefined) return options?.force === true ? undefined : yield* systemError("ENOENT", "rm", file) + if (info.type === "directory" && options?.recursive !== true) return yield* systemError("EISDIR", "rm", file) + // Every file inside a removed directory is authorized, so an edit rule for one of them still applies. + yield* host.fileWrite.authorize(yield* tree(host, target, info), host.execution) + yield* host.files.remove(target.absolute) + }), + ) + +const unlink = (file: PathLike) => + run("unlink", file, (host, file) => + Effect.gen(function* () { + const target = yield* host.fileWrite.target(file, host.execution) + const info = yield* host.files.stat(target.absolute) + if (info.type === "directory") return yield* systemError("EISDIR", "unlink", file) + yield* host.fileWrite.authorize(target, host.execution) + yield* host.files.remove(target.absolute) + }), + ) + +const rename = (from: PathLike, to: PathLike) => + run("rename", from, (host, file) => + Effect.gen(function* () { + const source = yield* host.fileWrite.target(file, host.execution) + const destination = yield* host.fileWrite.target(toPath(to), host.execution) + const info = yield* host.files.stat(source.absolute) + const existing = yield* optionalStat(host, destination.absolute) + if (existing?.type === "directory") + return yield* systemError(info.type === "directory" ? "ENOTEMPTY" : "EISDIR", "rename", file) + if (existing && info.type === "directory") return yield* systemError("ENOTDIR", "rename", file) + const moved = yield* tree(host, source, info) + const arriving = yield* Effect.forEach(moved.slice(1), (item) => + host.access.resolve({ + path: path.join(destination.absolute, path.relative(source.absolute, item.absolute)), + kind: "file", + }), + ) + yield* host.fileWrite.authorize([...moved, destination, ...arriving], host.execution) + yield* host.files.move(source.absolute, destination.absolute) + }), + ) + +const copyFile = (from: PathLike, to: PathLike, _mode?: number) => + run("copyfile", from, (host, file) => + Effect.gen(function* () { + const source = yield* host.access.authorizeRead(file, host.execution) + yield* write(host, toPath(to), (yield* host.files.read(source.absolute)).bytes) + }), + ) + +const constants = { F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1, COPYFILE_EXCL: 1 } + +export const extension = Extension.make({ + name: "fs", + globals: { + fs: { + promises: { + readFile: Extension.async(readFile), + writeFile: Extension.async(writeFile), + appendFile: Extension.async(appendFile), + readdir: Extension.async(readdir), + stat: Extension.async(stat), + lstat: Extension.async(lstat), + access: Extension.async(access), + mkdir: Extension.async(mkdir), + rm: Extension.async(rm), + unlink: Extension.async(unlink), + rename: Extension.async(rename), + copyFile: Extension.async(copyFile), + constants, + }, + // Sync variants return the same Effect, which a synchronous global blocks on. + readFileSync: (...args: Parameters) => readFile(...args), + writeFileSync: (...args: Parameters) => writeFile(...args), + appendFileSync: (...args: Parameters) => appendFile(...args), + readdirSync: (...args: Parameters) => readdir(...args), + statSync: (...args: Parameters) => stat(...args), + lstatSync: (...args: Parameters) => lstat(...args), + accessSync: (...args: Parameters) => access(...args), + existsSync, + mkdirSync: (...args: Parameters) => mkdir(...args), + rmSync: (...args: Parameters) => rm(...args), + unlinkSync: (...args: Parameters) => unlink(...args), + renameSync: (...args: Parameters) => rename(...args), + copyFileSync: (...args: Parameters) => copyFile(...args), + constants, + }, + }, +}) + +/** What to show for an fs call: the method and the path, plus the destination of a rename or copy. */ +export const display = (name: string, args: ReadonlyArray) => { + const method = name.slice(name.lastIndexOf(".") + 1) + const shown: Record = { method, path: String(args[0]) } + if (method.startsWith("rename") || method.startsWith("copyFile")) shown.destination = String(args[1]) + return shown +} + +const run = (syscall: string, file: PathLike, body: (host: Host, file: string) => Effect.Effect) => { + const resolved = toPath(file) + return Effect.gen(function* () { + const host: Host = { + execution: yield* CodeModeExecution.Service, + access: yield* FileAccess.Service, + fileWrite: yield* FileWrite.Service, + mutation: yield* FileMutation.Service, + files: (yield* Environment.Service).files, + } + return yield* body(host, resolved) + }).pipe(Effect.mapError((error) => nodeError(error, syscall, resolved))) +} + +type Host = { + readonly execution: ToolContext + readonly access: FileAccess.Interface + readonly fileWrite: FileWrite.Interface + readonly mutation: FileMutation.Interface + readonly files: Environment.Files +} + +// Text, including bytes that are valid UTF-8, goes through FileWrite like the write tool; other bytes are written as is +// after the same approval, without a diff. +const write = (host: Host, file: string, content: Data) => + Effect.gen(function* () { + const text = typeof content === "string" ? content : utf8(content) + if (text !== undefined) return void (yield* host.fileWrite.write({ path: file, content: text }, host.execution)) + const target = yield* host.fileWrite.target(file, host.execution) + yield* host.fileWrite.authorize(target, host.execution) + yield* host.mutation.write({ target, content }) + }) + +const utf8 = (bytes: Uint8Array) => { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) + } catch { + return undefined + } +} + +const statOf = (host: Host, file: string, follow: boolean) => + Effect.gen(function* () { + const target = yield* host.access.authorizeRead(file, host.execution) + const info = yield* host.files.stat(target.absolute) + const followed = + follow && info.type === "symlink" + ? yield* host.files.read(target.absolute, { offset: 0, length: 0 }).pipe( + Effect.map((result) => result.info), + Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed({ ...info, type: error.actual })), + ) + : info + return { size: followed.size, mtimeMs: followed.mtimeMs, mtime: new Date(followed.mtimeMs), ...kind(followed.type) } + }) + +const optionalStat = (host: Host, absolute: string) => + host.files.stat(absolute).pipe(Effect.catchTag("Environment.NotFound", () => Effect.undefined)) + +const kind = (type: Environment.FileType) => ({ + isFile: () => type === "file", + isDirectory: () => type === "directory", + isSymbolicLink: () => type === "symlink", +}) + +// A target and, for a directory, everything inside it. +const tree = (host: Host, target: FileAccess.Target, info: Environment.FileInfo) => + info.type !== "directory" + ? Effect.succeed([target]) + : walk(host.files, target.absolute, true).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => + host.access.resolve({ + path: path.join(target.absolute, entry.relative), + kind: entry.type === "directory" ? "directory" : "file", + }), + ), + ), + Effect.map((inside) => [target, ...inside]), + ) + +type Entry = { readonly relative: string; readonly type: Environment.FileType } + +const walk = ( + files: Environment.Files, + directory: string, + recursive: boolean, + prefix = "", +): Effect.Effect, Environment.NotFound | Environment.WrongKind | Environment.Failed> => + files.list(directory).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => { + const item = { relative: prefix === "" ? entry.name : path.join(prefix, entry.name), type: entry.type } + if (!recursive || entry.type !== "directory") return Effect.succeed([item]) + return walk(files, path.join(directory, entry.name), true, item.relative).pipe( + Effect.map((inside) => [item, ...inside]), + ) + }), + ), + Effect.map((items) => items.flat()), + ) + +const encodingOf = (options: EncodingOption) => (typeof options === "string" ? options : options?.encoding) ?? undefined + +const bytesOrText = (data: Data, options: EncodingOption): Data => { + const encoding = encodingOf(options) + if (typeof data !== "string" || encoding === undefined || encoding === "utf8" || encoding === "utf-8") return data + return Buffer.from(data, encoding) +} + +const toPath = (file: PathLike) => { + if (file instanceof URL) return fileURLToPath(file) + if (typeof file === "string") return file + throw new TypeError(`The "path" argument must be a string or a file URL. Received ${typeof file}`) +} + +const descriptions = { + ENOENT: [-2, "no such file or directory"], + EEXIST: [-17, "file already exists"], + EISDIR: [-21, "illegal operation on a directory"], + ENOTDIR: [-20, "not a directory"], + ENOTEMPTY: [-66, "directory not empty"], +} as const + +const systemError = (code: keyof typeof descriptions, syscall: string, file: string) => + Effect.fail(nodeSystemError(code, syscall, file)) + +const nodeSystemError = (code: string, syscall: string, file: string, message?: string) => { + const known = code in descriptions ? descriptions[code as keyof typeof descriptions] : undefined + return Object.assign(new Error(known ? `${code}: ${known[1]}, ${syscall} '${file}'` : (message ?? code)), { + ...(known ? { errno: known[0] } : {}), + code, + syscall, + path: file, + }) +} + +const nodeError = (error: unknown, syscall: string, file: string) => { + if (error instanceof Environment.NotFound) return nodeSystemError("ENOENT", syscall, file) + if (error instanceof Environment.WrongKind) + return nodeSystemError(error.actual === "directory" ? "EISDIR" : "ENOTDIR", syscall, file) + if (error instanceof Environment.Failed) { + const cause: unknown = error.cause + const code = cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause.code : undefined + if (code !== undefined && cause instanceof Error) return nodeSystemError(code, syscall, file, cause.message) + return new Error(`${syscall} '${file}' failed: ${cause instanceof Error ? cause.message : String(cause)}`) + } + // The feedback is the user talking to the model, so it must reach the program as the error's message. + if (error instanceof Permission.CorrectedError) + return new Error(`The user rejected ${syscall} ${file}: ${error.feedback}`) + return error +} diff --git a/packages/core/src/codemode/path.ts b/packages/core/src/codemode/path.ts new file mode 100644 index 00000000000..75267424b5b --- /dev/null +++ b/packages/core/src/codemode/path.ts @@ -0,0 +1,9 @@ +export * as CodeModePath from "./path.js" + +import { Extension } from "@opencode/codemode" +import path from "path" + +// Only the functions and separators cross: `posix` and `win32` point back at the module, which a global cannot hold. +const { posix: _posix, win32: _win32, ...functions } = path.posix + +export const extension = Extension.make({ name: "path", globals: { path: functions } }) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 1fd67439350..1c4687d45ec 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -18,6 +18,8 @@ import { Permission } from "../permission.js" import { CodeModeCatalog } from "./catalog.js" import { CodeModeExecution } from "./execution.js" import { CodeModeFetch } from "./fetch.js" +import { CodeModeFs } from "./fs.js" +import { CodeModePath } from "./path.js" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -74,7 +76,7 @@ export type Inventory = { // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions. const description = [ "Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.", - "`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.", + "`fetch` is available for HTTP requests, and Node's `fs` (with `fs.promises`) and `path` for files, also importable as `node:fs`, `fs/promises` and `node:path`. Other modules and packages, and timers, are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.", "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.", 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.["tool-name"](input)`.', "Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.", @@ -85,6 +87,7 @@ export const create = ( inventory: Inventory, executeTool: (name: string, tool: Info, input: unknown, context: ToolContext) => Effect.Effect, permission: Permission.Interface, + fileServices: Context.Context, ) => { return { name: "execute", @@ -147,6 +150,7 @@ export const create = ( ), Effect.provideService(CodeModeExecution.Service, context), Effect.provideService(Permission.Service, permission), + Effect.provideContext(fileServices), ) const toolCalls = yield* Ref.get(calls) const collected = (yield* Ref.get(files)) @@ -210,6 +214,8 @@ function progressHooks(record: (update: (items: Array) => Array>() +const runtimes = new WeakMap< + Inventory, + CodeMode.Runtime +>() function runtime(inventory: Inventory) { const cached = runtimes.get(inventory) if (cached) return cached const tools = inventoryTools(inventory) - const created = CodeMode.make({ + const created = CodeMode.make< + typeof tools, + [typeof CodeModeFetch.extension, typeof CodeModeFs.extension, typeof CodeModePath.extension] + >({ tools, - extensions: [CodeModeFetch.extension], + extensions: [CodeModeFetch.extension, CodeModeFs.extension, CodeModePath.extension], hooks: runHooks, }) runtimes.set(inventory, created) @@ -278,7 +290,7 @@ function runtime(inventory: Inventory) { } /** - * Runs one program over `inventory` alone with no model step and no `fetch`, binding `bindings` as outer globals. + * Runs one program over `inventory` alone with no model step and no `fetch`, `fs` or `path`, binding `bindings` as outer globals. */ export const check = ( inventory: Inventory, diff --git a/packages/core/src/file-write.ts b/packages/core/src/file-write.ts index 2afb8d3b85d..7b5e04d7e2a 100644 --- a/packages/core/src/file-write.ts +++ b/packages/core/src/file-write.ts @@ -28,9 +28,9 @@ export interface Interface { file: string, context: FileAccess.Invocation, ) => Effect.Effect - /** Request edit approval for a resolved target, showing the change when it is known. */ + /** Request edit approval for resolved targets in one request, showing the change when it is known. */ readonly authorize: ( - target: FileAccess.Target, + target: FileAccess.Target | ReadonlyArray, context: FileAccess.Invocation, preview?: FileDiff.Info, ) => Effect.Effect @@ -67,13 +67,13 @@ const layer = Layer.effect( }) const authorize = Effect.fn("FileWrite.authorize")(function* ( - target: FileAccess.Target, + target: FileAccess.Target | ReadonlyArray, context: FileAccess.Invocation, preview?: FileDiff.Info, ) { yield* permission.assert({ action: "edit", - resources: [target.resource], + resources: [...new Set((Array.isArray(target) ? target : [target]).map((item) => item.resource))], save: ["*"], metadata: preview ? { files: [preview] } : undefined, ...Permission.invocation(context), diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index ad4357a029f..9be5f0b182b 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -9,7 +9,12 @@ import { Context, Effect, Layer, Ref, Result, Schema, SchemaIssue, Types } from import { makeLocationNode } from "@opencode/util/effect/app-node" import type { Agent } from "./agent.js" import { CodeModeCatalog } from "./codemode/catalog.js" +import type { CodeModeFs } from "./codemode/fs.js" import { CodeModeTool } from "./codemode/tool.js" +import { Environment } from "./environment/index.js" +import { FileAccess } from "./file-access.js" +import { FileMutation } from "./file-mutation.js" +import { FileWrite } from "./file-write.js" import { Image } from "./image.js" import { Permission } from "./permission.js" import { PluginHooks } from "./plugin/hooks.js" @@ -87,6 +92,7 @@ const layer = Layer.effect( const hooks = yield* PluginHooks.Service const image = yield* Image.Service const permission = yield* Permission.Service + const files = yield* Effect.context() type NormalizedItem = Tool.Content | "decode" | "size" const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray) { @@ -274,6 +280,7 @@ const layer = Layer.effect( codeModeInventory, (name, tool, input, context) => run(tool, name, input, context), permission, + files, ) : undefined const codeModeCatalog = !codeModeEnabled @@ -415,5 +422,13 @@ function namespaceError(name: string) { export const node = makeLocationNode({ service: Service, layer, - deps: [PluginHooks.node, Image.node, Permission.node], + deps: [ + PluginHooks.node, + Image.node, + Permission.node, + Environment.node, + FileAccess.node, + FileMutation.node, + FileWrite.node, + ], }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 1e32da07e3e..27739b95e41 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -68,7 +68,7 @@ import { advance, drain } from "./lib/clock" import { testEffect } from "./lib/effect" import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy" import { imagePassthrough } from "./lib/image" -import { location } from "./fixture/location" +import { location, tempLocationLayer } from "./fixture/location" import { tmpdirScoped } from "./fixture/tmpdir" import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment" import { @@ -586,6 +586,7 @@ const it = testEffect( Permission.node.replace(permissions), Bus.node.replace(events), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), Global.node.replace(Global.layerWith({ data: outputData })), ]), ) @@ -2534,6 +2535,7 @@ testEffect(Layer.empty).live("preserves plugin transforms through MCP catalog up Effect.provide( Layer.fresh( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ + Location.node.replace(tempLocationLayer), Mcp.node.replace( Layer.mock(Mcp.Service, { awaitStartup: () => Effect.void, @@ -2586,6 +2588,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ + Location.node.replace(tempLocationLayer), Mcp.node.replace( Layer.mock(Mcp.Service, { awaitStartup: () => Effect.void, @@ -2626,6 +2629,7 @@ const startupRegistry = (url: string) => Bus.node.replace(events), Image.node.replace(imagePassthrough), Global.node.replace(Global.layerWith({ data: outputData })), + Location.node.replace(tempLocationLayer), ]) testEffect(Layer.empty).effect("flush waits for a server that connects within its startup window", () => diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index b8a16732e24..2844ebd6c56 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -1,16 +1,32 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import type { CodeModeFs } from "@opencode/core/codemode/fs" import { CodeModeTool } from "@opencode/core/codemode/tool" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Environment } from "@opencode/core/environment/index" +import { FileAccess } from "@opencode/core/file-access" +import { FileMutation } from "@opencode/core/file-mutation" +import { FileWrite } from "@opencode/core/file-write" +import { Formatter } from "@opencode/core/formatter" +import { Location } from "@opencode/core/location" import { Permission } from "@opencode/core/permission" +import { AbsolutePath } from "@opencode/core/schema" import { Tool } from "@opencode/core/tool" import { execute } from "@opencode/core/tool/runtime" import { Agent } from "@opencode/schema/agent" import { Session } from "@opencode/schema/session" import { SessionMessage } from "@opencode/schema/session-message" import type { Info } from "@opencode/schema/tool" -import { Deferred, Effect, Fiber, Schema } from "effect" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Deferred, Effect, Fiber, Layer, Schema } from "effect" import { TestClock } from "effect/testing" +import { hostEnvironmentLayer } from "./fixture/environment" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -import { permissionService } from "./lib/permission" +import { permissionService, permissionLayer } from "./lib/permission" const context = { sessionID: Session.ID.make("ses_execute"), @@ -22,14 +38,33 @@ const context = { const allowAll = permissionService({ assert: () => Effect.void }) -const createCodeMode = (tools: ReadonlyMap, permission = allowAll) => - CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context), permission) +// The file services of a Location at `directory`, asking `permission` like the execute call's own. +const fileServices = (directory: string, permission: Partial = { assert: () => Effect.void }) => + Effect.runSync( + Effect.context().pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([Environment.node, FileAccess.node, FileMutation.node, FileWrite.node]), [ + Environment.node.replace(hostEnvironmentLayer), + Location.node.replace( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ), + Formatter.node.replace(Layer.mock(Formatter.Service, { file: () => Effect.succeed(false) })), + Permission.node.replace(permissionLayer(permission)), + ]), + ), + ), + ) + +const unusedFiles = fileServices(os.tmpdir()) + +const createCodeMode = (tools: ReadonlyMap, permission = allowAll, files = unusedFiles) => + CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context), permission, files) test("execute describes invariant Code Mode behavior", () => { expect(createCodeMode(new Map()).description).toBe( [ "Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.", - "`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.", + "`fetch` is available for HTTP requests, and Node's `fs` (with `fs.promises`) and `path` for files, also importable as `node:fs`, `fs/promises` and `node:path`. Other modules and packages, and timers, are unavailable: there is no `setTimeout` or sleep. All other external access goes through `tools`.", "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.", 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.["tool-name"](input)`.', "Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.", @@ -143,6 +178,92 @@ describe("fetch permissions", () => { }) }) +describe("fs", () => { + const source = { type: "tool" as const, messageID: context.messageID, id: context.id } + + test("fs reads and writes files in the session's directory, asking read and edit permission as the execute call", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, "notes.txt"), "hello") + const asked: Array = [] + const record = { assert: (input: Permission.AssertInput) => Effect.sync(() => void asked.push(input)) } + const result = await Effect.runPromise( + createCodeMode(new Map(), permissionService(record), fileServices(tmp.path, record)).execute( + { + code: `import { readFile } from "node:fs/promises" + const text = await readFile("notes.txt", "utf8") + fs.writeFileSync("out/copy.txt", text + " world") + return [text, fs.readdirSync("out"), path.join("out", "..", "notes.txt")]`, + }, + context, + ), + ) + + expect(JSON.parse(result.output.output)).toEqual(["hello", ["copy.txt"], "notes.txt"]) + expect(await Bun.file(path.join(tmp.path, "out", "copy.txt")).text()).toBe("hello world") + expect(asked.map((input) => [input.action, input.resources, input.sessionID, input.source])).toEqual([ + ["read", ["notes.txt"], context.sessionID, source], + ["edit", ["out/copy.txt"], context.sessionID, source], + ["read", ["out"], context.sessionID, source], + ]) + expect(asked[1]?.metadata).toMatchObject({ files: [{ file: "out/copy.txt", status: "added", additions: 1 }] }) + expect(result.output.toolCalls).toEqual([ + { tool: "fs", status: "completed", input: { method: "readFile", path: "notes.txt" } }, + { tool: "fs", status: "completed", input: { method: "writeFileSync", path: "out/copy.txt" } }, + { tool: "fs", status: "completed", input: { method: "readdirSync", path: "out" } }, + ]) + }) + + test("a denied write never touches disk: the sync call throws and the promise rejects", async () => { + await using tmp = await tmpdir() + const denyEdits = { + assert: (input: Permission.AssertInput) => + input.action === "edit" + ? Effect.fail( + new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources }), + ) + : Effect.void, + } + const result = await Effect.runPromise( + createCodeMode(new Map(), permissionService(denyEdits), fileServices(tmp.path, denyEdits)).execute( + { + code: `let thrown + try { fs.writeFileSync("a.txt", "x") } catch (e) { thrown = e.message } + const [rejected] = await Promise.allSettled([fs.promises.writeFile("b.txt", "x")]) + return [thrown, rejected.status, fs.existsSync("a.txt")]`, + }, + context, + ), + ) + + const [thrown, status, exists] = JSON.parse(result.output.output) + expect(thrown).toContain("Permission denied") + expect([status, exists]).toEqual(["rejected", false]) + expect(await fs.readdir(tmp.path)).toEqual([]) + }) + + test("a missing file fails with a Node-shaped ENOENT error", async () => { + await using tmp = await tmpdir() + const result = await Effect.runPromise( + createCodeMode(new Map(), allowAll, fileServices(tmp.path)).execute( + { + code: `try { await fs.promises.readFile("missing.txt") } catch (e) { + return [e instanceof Error, e.code, e.syscall, e.path, e.message] + }`, + }, + context, + ), + ) + + expect(JSON.parse(result.output.output)).toEqual([ + true, + "ENOENT", + "open", + "missing.txt", + "ENOENT: no such file or directory, open 'missing.txt'", + ]) + }) +}) + test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => { const declared: Info = { name: "declared", @@ -279,10 +400,20 @@ test("executions share the prepared runtime of an inventory while keeping per-ca execute: (input, context) => Effect.succeed({ content: `${context.id}:${(input as { value: string }).value}` }), } const inventory = { tools: new Map([["echo", echo]]) } - const codeMode = CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context), allowAll) + const codeMode = CodeModeTool.create( + inventory, + (_, tool, input, context) => execute(tool, input, context), + allowAll, + unusedFiles, + ) const run = (id: string) => { const progress: Array = [] - return CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context), allowAll) + return CodeModeTool.create( + inventory, + (_, tool, input, context) => execute(tool, input, context), + allowAll, + unusedFiles, + ) .execute( { code: `return await tools.echo({ value: "${id}" })` }, { ...context, id: Tool.CallID.make(id), progress: (update) => Effect.sync(() => progress.push(update)) }, diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index bd397f8ea45..d78603fcfcb 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -6,6 +6,8 @@ import { Form } from "@opencode/core/form" import { Permission } from "@opencode/core/permission" import { Session } from "@opencode/core/session" import { Tool } from "@opencode/core/tool" +import { tempLocationLayer } from "./fixture/location" +import { Location } from "@opencode/core/location" import { QuestionTool } from "@opencode/core/tool/plugin/question" import { Image } from "@opencode/core/image" import { testEffect } from "./lib/effect" @@ -69,6 +71,7 @@ const it = testEffect( Permission.node.replace(permission), Form.node.replace(form), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), ]), ) diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index 3e68b22c4e9..44d293d030a 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -8,6 +8,8 @@ import { Session } from "@opencode/core/session" import { SessionMessage } from "@opencode/core/session/message" import { State } from "@opencode/core/state" import { Tool } from "@opencode/core/tool" +import { tempLocationLayer } from "./fixture/location" +import { Location } from "@opencode/core/location" import type { Info } from "@opencode/schema/tool" import { LayerNode } from "@opencode/util/effect/layer-node" import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool" @@ -40,6 +42,7 @@ const imageStore = Layer.mock(Image.Service, { }) const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [ Image.node.replace(imageStore), + Location.node.replace(tempLocationLayer), Permission.node.replace(permissionLayer()), ]) const it = testEffect(registryLayer) diff --git a/packages/core/test/tool-schedule.test.ts b/packages/core/test/tool-schedule.test.ts index 63b7dd83e5a..213c8d282a5 100644 --- a/packages/core/test/tool-schedule.test.ts +++ b/packages/core/test/tool-schedule.test.ts @@ -7,6 +7,8 @@ import { Permission } from "@opencode/core/permission" import { Schedule } from "@opencode/core/schedule" import { Session } from "@opencode/core/session" import { Tool } from "@opencode/core/tool" +import { tempLocationLayer } from "./fixture/location" +import { Location } from "@opencode/core/location" import { ScheduleTools } from "@opencode/core/tool/plugin/schedule" import { Document, Info, type Entry } from "@opencode/schema/config" import { ConfigExperimental } from "@opencode/schema/config/experimental" @@ -24,7 +26,11 @@ const names = ["schedule_create", "schedule_list", "schedule_pause", "schedule_r ) const it = testEffect( - AppNodeBuilder.build(Tool.node, [Image.node.replace(imagePassthrough), Permission.node.replace(permissionLayer())]), + AppNodeBuilder.build(Tool.node, [ + Image.node.replace(imagePassthrough), + Permission.node.replace(permissionLayer()), + Location.node.replace(tempLocationLayer), + ]), ) /** An in-memory Schedule service. The real one is covered in schedule.test.ts; this exercises the tool layer. */ diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index c4338278e04..03443bf04b0 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -14,6 +14,8 @@ import { SkillTool } from "@opencode/core/tool/plugin/skill" import { Tool } from "@opencode/core/tool" import { tmpdirScoped } from "./fixture/tmpdir" import { Image } from "@opencode/core/image" +import { Location } from "@opencode/core/location" +import { tempLocationLayer } from "./fixture/location" import { it } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { permissionLayer } from "./lib/permission" @@ -61,6 +63,8 @@ describe("SkillTool", () => { Config.node.replace(Config.testLayer()), Permission.node.replace(permissionLayer({ assert: () => Effect.die("Blocked skill reached authorization") })), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), + Location.node.replace(tempLocationLayer), ]), ), ), @@ -113,6 +117,9 @@ describe("SkillTool", () => { Permission.node.replace(permission), Skill.node.replace(skills), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), + Location.node.replace(tempLocationLayer), + Location.node.replace(tempLocationLayer), ]) return yield* Effect.gen(function* () { diff --git a/packages/core/test/tool-todo.test.ts b/packages/core/test/tool-todo.test.ts index 01162831ae9..71d354a9ef7 100644 --- a/packages/core/test/tool-todo.test.ts +++ b/packages/core/test/tool-todo.test.ts @@ -6,6 +6,8 @@ import { Image } from "@opencode/core/image" import { Permission } from "@opencode/core/permission" import { Session } from "@opencode/core/session" import { Tool } from "@opencode/core/tool" +import { tempLocationLayer } from "./fixture/location" +import { Location } from "@opencode/core/location" import { TodoTool } from "@opencode/core/tool/plugin/todo" import { Document, Info, type Entry } from "@opencode/schema/config" import { ConfigExperimental } from "@opencode/schema/config/experimental" @@ -24,7 +26,11 @@ const todos = [ ] as const const it = testEffect( - AppNodeBuilder.build(Tool.node, [Image.node.replace(imagePassthrough), Permission.node.replace(permissionLayer())]), + AppNodeBuilder.build(Tool.node, [ + Image.node.replace(imagePassthrough), + Permission.node.replace(permissionLayer()), + Location.node.replace(tempLocationLayer), + ]), ) const setup = (experimental: ConfigExperimental.Info | undefined) => diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 1b6cbe85f31..fe09e022d8b 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -8,6 +8,8 @@ import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform" import { Permission } from "@opencode/core/permission" import { Session } from "@opencode/core/session" import { Tool } from "@opencode/core/tool" +import { tempLocationLayer } from "./fixture/location" +import { Location } from "@opencode/core/location" import { WebFetchTool } from "@opencode/core/tool/plugin/webfetch" import { makeLocationNode } from "@opencode/util/effect/app-node" import { Image } from "@opencode/core/image" @@ -58,6 +60,7 @@ const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [ Permission.node.replace(permission), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), ...replacements, ]) const it = testEffect(toolLayer([LayerNodePlatform.httpClient.replace(http)])) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index c3f1fcf5d23..7f04153a25f 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -13,6 +13,8 @@ import { Tool } from "@opencode/core/tool" import { WebSearchTool } from "@opencode/core/tool/plugin/websearch" import { makeLocationNode } from "@opencode/util/effect/app-node" import { Image } from "@opencode/core/image" +import { Location } from "@opencode/core/location" +import { tempLocationLayer } from "./fixture/location" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { permissionLayer } from "./lib/permission" @@ -91,6 +93,7 @@ const setup = Effect.gen(function* () { }), ), Image.node.replace(imagePassthrough), + Location.node.replace(tempLocationLayer), ]), ) return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })