feat(core): add Node-style fs and path to Code Mode

This commit is contained in:
Aiden Cline
2026-11-01 22:11:06 -06:00
Unverified
parent 06de01cd1c
commit ed10e40ee6
14 changed files with 576 additions and 20 deletions
+354
View File
@@ -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<typeof readFile>) => readFile(...args),
writeFileSync: (...args: Parameters<typeof writeFile>) => writeFile(...args),
appendFileSync: (...args: Parameters<typeof appendFile>) => appendFile(...args),
readdirSync: (...args: Parameters<typeof readdir>) => readdir(...args),
statSync: (...args: Parameters<typeof stat>) => stat(...args),
lstatSync: (...args: Parameters<typeof lstat>) => lstat(...args),
accessSync: (...args: Parameters<typeof access>) => access(...args),
existsSync,
mkdirSync: (...args: Parameters<typeof mkdir>) => mkdir(...args),
rmSync: (...args: Parameters<typeof rm>) => rm(...args),
unlinkSync: (...args: Parameters<typeof unlink>) => unlink(...args),
renameSync: (...args: Parameters<typeof rename>) => rename(...args),
copyFileSync: (...args: Parameters<typeof copyFile>) => 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<unknown>) => {
const method = name.slice(name.lastIndexOf(".") + 1)
const shown: Record<string, string> = { method, path: String(args[0]) }
if (method.startsWith("rename") || method.startsWith("copyFile")) shown.destination = String(args[1])
return shown
}
const run = <A, E, R>(syscall: string, file: PathLike, body: (host: Host, file: string) => Effect.Effect<A, E, R>) => {
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<Array<Entry>, 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
}
+9
View File
@@ -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 } })
+17 -5
View File
@@ -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.<namespace>["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<Result, Error>,
permission: Permission.Interface,
fileServices: Context.Context<CodeModeFs.Services>,
) => {
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<ExecuteCall>) => Array<Exe
case "fetch":
return start(call, { tool: call.name, status: "running", input: CodeModeFetch.display(call.args) })
default:
if (call.extension === "fs")
return start(call, { tool: "fs", status: "running", input: CodeModeFs.display(call.name, call.args) })
return Effect.void
}
},
@@ -262,15 +268,21 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
// Inventories are immutable snapshots, so tool definitions, schemas, and the search index are built
// once per inventory and shared by its catalog and every execution.
const runtimes = new WeakMap<Inventory, CodeMode.Runtime<Run | CodeModeExecution.Service | Permission.Service>>()
const runtimes = new WeakMap<
Inventory,
CodeMode.Runtime<Run | CodeModeExecution.Service | Permission.Service | CodeModeFs.Services>
>()
function runtime(inventory: Inventory) {
const cached = runtimes.get(inventory)
if (cached) return cached
const tools = inventoryTools(inventory)
const created = CodeMode.make<typeof tools, [typeof CodeModeFetch.extension]>({
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,
+4 -4
View File
@@ -28,9 +28,9 @@ export interface Interface {
file: string,
context: FileAccess.Invocation,
) => Effect.Effect<FileAccess.Target, Environment.Failed | Permission.Error | SessionErrors.NotFoundError>
/** 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<FileAccess.Target>,
context: FileAccess.Invocation,
preview?: FileDiff.Info,
) => Effect.Effect<void, Permission.Error | SessionErrors.NotFoundError>
@@ -67,13 +67,13 @@ const layer = Layer.effect(
})
const authorize = Effect.fn("FileWrite.authorize")(function* (
target: FileAccess.Target,
target: FileAccess.Target | ReadonlyArray<FileAccess.Target>,
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),
+16 -1
View File
@@ -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<CodeModeFs.Services>()
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
@@ -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,
],
})
+5 -1
View File
@@ -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", () =>
+138 -7
View File
@@ -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<string, Info>, 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<Permission.Interface> = { assert: () => Effect.void }) =>
Effect.runSync(
Effect.context<CodeModeFs.Services>().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<string, Info>, 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.<namespace>["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<Permission.AssertInput> = []
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<Tool.Metadata> = []
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)) },
+3
View File
@@ -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),
]),
)
+3
View File
@@ -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)
+7 -1
View File
@@ -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. */
+7
View File
@@ -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* () {
+7 -1
View File
@@ -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) =>
+3
View File
@@ -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)]))
@@ -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) })