fix(core): route hosted read tool through the workspace environment

This commit is contained in:
Kit Langton
2026-08-06 17:43:44 -04:00
parent d7ecb3af37
commit eaa196d574
12 changed files with 288 additions and 109 deletions
+1 -3
View File
@@ -1551,9 +1551,7 @@ export interface WebsearchApi<E = never> {
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
@@ -472,7 +472,7 @@ export function make(options: ClientOptions) {
location: input?.["location"],
},
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -4632,9 +4632,7 @@ export type WebsearchQueryOutput = {
}
export type ConfigGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type ConfigGetOutput = Array<ConfigEntry>
@@ -5,6 +5,9 @@ export default {
id: "20260806150823_remove_workspace",
up(tx) {
return Effect.gen(function* () {
// Legacy control-plane workspace references would otherwise route these
// sessions to hosted graphs whose workspace rows no longer exist.
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DROP TABLE \`workspace\`;`)
})
},
+1
View File
@@ -135,6 +135,7 @@ export function buildLocationServiceMap(
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[ReadToolFileSystem.node, ReadToolFileSystem.hostedNode],
[Shell.node, Shell.hostedNode],
]
: [[Location.node, Location.boundNode(ref)]],
+87 -90
View File
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
}),
})
export const Input = LocationInput
const Output = Schema.Union([
ReadToolFileSystem.FileContent,
ReadToolFileSystem.TextPage,
ReadToolFileSystem.ListPage,
])
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
export const Plugin = {
id: "opencode.tool.read",
@@ -43,99 +39,100 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false },
description:
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
draft.add({
name,
options: { codemode: false },
description:
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const type = yield* reader.inspect(absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
// Discovery walks the host filesystem; hosted instruction
// discovery needs an environment-backed walk first.
if (location.workspaceID !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
},
}),
),
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
)
},
}),
)
.pipe(Effect.orDie)
+140 -7
View File
@@ -2,11 +2,13 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema, Scope } from "effect"
import { systemError } from "effect/PlatformError"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
import { WorkspaceEnvironment } from "../workspace/environment"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
@@ -109,6 +111,25 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
export interface ReadSource {
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
readonly open: (
path: string,
options: { readonly flag: "r" },
) => Effect.Effect<
{
readonly stat: Effect.Effect<
{ readonly type: WorkspaceEnvironment.FileInfo["type"]; readonly size: bigint | number },
FSUtil.Error
>
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
},
FSUtil.Error,
Scope.Scope
>
}
const extensions = new Set([
".zip",
".tar",
@@ -169,7 +190,14 @@ const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array)
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
export const inspect = Effect.fn("ReadTool.inspect")(function* (
fs: {
readonly stat: (
path: string,
) => Effect.Effect<{ readonly type: WorkspaceEnvironment.FileInfo["type"] }, FSUtil.Error>
},
input: string,
) {
const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
@@ -177,7 +205,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Inter
})
export const read = Effect.fn("ReadTool.read")(function* (
fs: FSUtil.Interface,
fs: ReadSource,
input: string,
resource: string,
page: PageInput = {},
@@ -353,9 +381,16 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
}),
{ concurrency: 16 },
)
const visible = entries
.filter((item): item is FileSystem.Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
const visible = entries.filter((item): item is FileSystem.Entry => item !== undefined)
return pageEntries(visible, { offset, limit })
})
const pageEntries = (entries: FileSystem.Entry[], page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = [...entries].sort((a, b) =>
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1,
)
const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length
return new ListPage({
@@ -364,7 +399,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
})
}
const layer = Layer.effect(
Service,
@@ -379,3 +414,101 @@ const layer = Layer.effect(
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Reads through WorkspaceEnvironment.Files so hosted sessions never touch the
// host filesystem. The environment has no ranged read, so file reads transfer
// the whole file once and page in memory.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
// Absence surfaces as a platform NotFound so the read tool's missing-file
// handling stays backend-agnostic; other failures become filesystem errors.
const mapEnv =
(method: string) =>
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
: new FSUtil.FileSystemError({ method, cause: error })
const source: ReadSource = {
realPath: (target) => env.files.realPath(target).pipe(Effect.mapError(mapEnv("realPath"))),
open: (target) =>
Effect.gen(function* () {
const info = yield* env.files.stat(target).pipe(Effect.mapError(mapEnv("stat")))
if (info.type !== "File")
return {
stat: Effect.succeed({ type: info.type, size: 0 }),
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
}
const bytes = yield* env.files.read(target).pipe(Effect.mapError(mapEnv("read")))
let cursor = 0
return {
stat: Effect.succeed({ type: info.type, size: bytes.length }),
readAlloc: (size: number) =>
Effect.sync(() => {
if (cursor >= bytes.length) return Option.none<Uint8Array>()
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
cursor += chunk.length
return Option.some(chunk)
}),
}
}),
}
// Matches the local list semantics: symlinks resolve and escape-filter
// against the listed directory. Non-symlink types come from the listing
// itself, keeping provider round trips proportional to symlink count.
const resolveSymlink = Effect.fnUntraced(function* (parent: string, name: string) {
const target = yield* env.files
.realPath(path.posix.join(parent, name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target === undefined || !FSUtil.containsPosix(parent, target)) return undefined
const info = yield* env.files.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "Directory") return "directory" as const
if (info?.type === "File") return "file" as const
return undefined
})
const hostedList = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
const real = yield* source.realPath(input)
const items = yield* env.files.list(real).pipe(Effect.mapError(mapEnv("list")))
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const type =
item.type === "file" || item.type === "directory"
? item.type
: item.type === "symlink"
? yield* resolveSymlink(real, item.name)
: undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? "/" : "")),
type,
})
}),
{ concurrency: 4 },
)
return pageEntries(
entries.filter((item): item is FileSystem.Entry => item !== undefined),
page,
)
})
return Service.of({
inspect: (target) =>
inspect({ stat: (statTarget) => env.files.stat(statTarget).pipe(Effect.mapError(mapEnv("stat"))) }, target),
read: (target, resource, page) => read(source, target, resource, page),
list: (target, page) => hostedList(target, page),
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
+32 -4
View File
@@ -4,6 +4,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
@@ -15,10 +17,13 @@ const memory = memoryEnvironment({
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode]), [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
AppNodeBuilder.build(
LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode, ReadToolFileSystem.hostedNode]),
[
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
],
),
)
describe("hosted mutation", () => {
@@ -64,4 +69,27 @@ describe("hosted mutation", () => {
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
// The reader must consume the environment, never the host filesystem:
// these paths exist only in the memory store.
it.effect("reads files and directories through the environment", () =>
Effect.gen(function* () {
const reader = yield* ReadToolFileSystem.Service
expect(yield* reader.inspect(AbsolutePath.make("/workspace/README.md"))).toBe("file")
expect(yield* reader.inspect(AbsolutePath.make("/workspace/src"))).toBe("directory")
const content = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md")
expect(content).toMatchObject({ type: "file", content: "# hello\n", encoding: "utf8" })
const paged = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md", { limit: 1 })
expect(paged).toMatchObject({ type: "text-page", content: "# hello", offset: 1, truncated: false })
const listed = yield* reader.list(AbsolutePath.make("/workspace/src"))
expect(listed.entries.map((entry) => String(entry.path))).toEqual(["index.ts"])
const missing = yield* reader.inspect(AbsolutePath.make("/workspace/nope.txt")).pipe(Effect.flip)
expect(missing).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } })
}),
)
})
@@ -134,6 +134,7 @@ describe("hosted workspace session", () => {
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
@@ -153,6 +154,7 @@ describe("hosted workspace session", () => {
expect(advertised).toContain("patch")
expect(advertised).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
@@ -174,6 +176,9 @@ describe("hosted workspace session", () => {
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
+1
View File
@@ -153,6 +153,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: InvalidRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
+9 -1
View File
@@ -81,7 +81,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
.pipe(
Effect.catchTag("Workspace.NotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
),
Effect.catchTag("Session.WorkspaceDirectoryError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
Effect.orDie,
),
}
}),
)
@@ -98,6 +98,9 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
// Read must go through the sandbox filesystem: the host has no
// /workspace/from-patch.txt, so a host-backed reader would fail.
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
@@ -109,6 +112,7 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
expect(advertised).toContain("patch")
expect(advertised).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
@@ -127,6 +131,9 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
// Both tools executed inside the sandbox: read the files back through