Compare commits

...
1 Commits
Author SHA1 Message Date
LukeParkerDev ec197eb4e4 feat(server): add fs.write endpoint
Clients can write base64 content to an absolute path or a path relative
to the requested location. Parent directories are created and the
resolved absolute path is returned. Unlike fs.read, the target is not
confined to the location so clients can stage files in the server tmp
directory reported by /api/info, which the model already prefers and is
permitted to access.

Also compares the canonical tmp path in the service info test: on
Windows CI os.tmpdir() is an 8.3 short name that the server realpaths.
2026-09-17 14:45:30 +10:00
10 changed files with 100 additions and 6 deletions
+2 -1
View File
@@ -263,7 +263,8 @@ test("concurrent service processes elect one server", async () => {
version: info.version,
pid: info.pid,
urls: [info.url],
paths: { tmp: path.join(os.tmpdir(), "opencode") },
// The server reports the canonical tmp directory; Windows os.tmpdir() can be an 8.3 short name.
paths: { tmp: await fs.realpath(path.join(os.tmpdir(), "opencode")) },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
+8
View File
@@ -1823,9 +1823,17 @@ export type FileFindInput = {
export type FileFindOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: FileFindInput) => Effect.Effect<FileFindOutput, E>
export type FileWriteInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly payload: FileSystem.WriteInput
}
export type FileWriteOutput = { readonly location: Location.PublicRef; readonly data: FileSystem.Write }
export type FileWriteOperation<E = never> = (input: FileWriteInput) => Effect.Effect<FileWriteOutput, E>
export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly find: FileFindOperation<E>
readonly write: FileWriteOperation<E>
}
export type CommandListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+15 -1
View File
@@ -179,6 +179,8 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWriteInput,
FileWriteOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -1139,7 +1141,19 @@ const EndpointFileFind = (raw: RawClient["server.fs"]) => (input: FileFindInput)
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupFile = (raw: RawClient["server.fs"]) => ({ list: EndpointFileList(raw), find: EndpointFileFind(raw) })
type FileWriteRequest = Parameters<RawClient["server.fs"]["fs.write"]>[0]
const EndpointFileWrite = (raw: RawClient["server.fs"]) => (input: FileWriteInput) =>
preserveEffect<FileWriteOutput>()(
raw["fs.write"]({ query: { location: input["location"] }, payload: input["payload"] } as FileWriteRequest).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupFile = (raw: RawClient["server.fs"]) => ({
list: EndpointFileList(raw),
find: EndpointFileFind(raw),
write: EndpointFileWrite(raw),
})
const EndpointCommandList = (raw: RawClient["server.command"]) => (input?: CommandListInput) =>
preserveEffect<CommandListOutput>()(
@@ -175,6 +175,8 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWriteInput,
FileWriteOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -1566,6 +1568,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
write: (input: FileWriteInput, requestOptions?: RequestOptions) =>
request<FileWriteOutput>(
{
method: "POST",
path: `/api/experimental/fs/write`,
query: { location: input["location"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
command: {
list: (input?: CommandListInput, requestOptions?: RequestOptions) =>
@@ -319,6 +319,8 @@ export type PermissionSavedInfo = {
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type FileSystemWrite = { path: string }
export type CommandInfo = { name: string; description?: string }
export type SkillInfo = {
@@ -5793,6 +5795,13 @@ export type FileFindInput = {
export type FileFindOutput = { location: LocationPublicRef; data: Array<FileSystemEntry> }
export type FileWriteInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly payload: { readonly path: string; readonly data: string }
}
export type FileWriteOutput = { location: LocationPublicRef; data: FileSystemWrite }
export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+12 -3
View File
@@ -5,9 +5,9 @@ import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { PositiveInt, RelativePath } from "./schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Entry, FileSystem, FindInput } from "@opencode/schema/filesystem"
import { Entry, FileSystem, FindInput, Write, WriteInput } from "@opencode/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -33,7 +33,7 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export { FindInput }
export { FindInput, Write, WriteInput }
export const DEFAULT_SEARCH_LIMIT = 100
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
@@ -62,6 +62,8 @@ export interface Interface {
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
/** Writes a file at an absolute path or one relative to the location; not confined to it. */
readonly write: (input: WriteInput) => Effect.Effect<Write>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
@@ -143,6 +145,13 @@ const baseLayer = Layer.effect(
),
)
}),
// Unlike read, write reaches outside the location so clients can stage files in the
// server tmp directory, which the model is already told to prefer and permitted to access.
write: Effect.fn("FileSystem.write")(function* (input) {
const target = path.resolve(location.directory, input.path)
yield* fs.writeWithDirs(target, Buffer.from(input.data, "base64")).pipe(Effect.orDie)
return Write.make({ path: AbsolutePath.make(target) })
}),
})
}),
)
+16
View File
@@ -65,6 +65,22 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
}),
),
)
.add(
HttpApiEndpoint.post("fs.write", "/api/experimental/fs/write", {
query: LocationQuery,
payload: FileSystem.WriteInput,
success: Location.response(FileSystem.Write),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "experimental.fs.write",
summary: "Write file",
description:
"Write base64 content to an absolute path or a path relative to the requested location, creating parent directories, and return the resolved absolute path. Unlike read, the target is not confined to the location. Experimental: may change without compatibility guarantees.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "filesystem",
+14 -1
View File
@@ -3,7 +3,8 @@ export * as FileSystem from "./filesystem.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { Base64 } from "./prompt.js"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
const Changed = ephemeral({
type: "filesystem.changed",
@@ -41,3 +42,15 @@ export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
type: Schema.Literals(["file", "directory"]).pipe(optional),
limit: PositiveInt.pipe(optional),
}) {}
export class WriteInput extends Schema.Class<WriteInput>("FileSystem.WriteInput")({
path: Schema.String.annotate({
description: "An absolute path or a path relative to the requested location. Missing parent directories are created.",
}),
data: Base64,
}) {}
export interface Write extends Schema.Schema.Type<typeof Write> {}
export const Write = Schema.Struct({
path: AbsolutePath,
}).annotate({ identifier: "FileSystem.Write" })
+8
View File
@@ -43,5 +43,13 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
}),
),
)
.handle("fs.write", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
return yield* fs.write(ctx.payload)
}),
),
)
}),
)
+1
View File
@@ -113,6 +113,7 @@ const fileSystemLayer = Layer.succeed(
read: () => unavailable("FileSystem.read"),
list: () => unavailable("FileSystem.list"),
find: () => unavailable("FileSystem.find"),
write: () => unavailable("FileSystem.write"),
}),
)