feat(server): add fs.write endpoint (#49466)

This commit is contained in:
Luke Parker
2026-09-17 20:17:14 +10:00
committed by GitHub
parent 73e7b7bc79
commit 1685e70c4b
10 changed files with 129 additions and 16 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 {
@@ -175,6 +175,8 @@ import type {
FileListOutput,
FileFindInput,
FileFindOutput,
FileWriteInput,
FileWriteOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -277,6 +279,7 @@ interface RequestDescriptor {
readonly query?: Record<string, unknown>
readonly headers?: Record<string, unknown>
readonly body?: unknown
readonly binaryBody?: true
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
@@ -296,14 +299,20 @@ export function make(options: ClientOptions) {
if (value !== undefined && value !== null) headers.set(key, String(value))
}
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
if (descriptor.body !== undefined && !headers.has("content-type"))
headers.set("content-type", descriptor.binaryBody ? "application/octet-stream" : "application/json")
return {
url,
init: {
method: descriptor.method,
signal: requestOptions?.signal,
headers,
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
body:
descriptor.body === undefined
? undefined
: descriptor.binaryBody
? (descriptor.body as RequestInit["body"])
: JSON.stringify(descriptor.body),
} satisfies RequestInit,
}
}
@@ -1566,6 +1575,20 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
write: (input: FileWriteInput, requestOptions?: RequestOptions) =>
request<FileWriteOutput>(
{
method: "POST",
path: `/api/experimental/fs/write`,
query: { location: input["location"], path: input["path"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binaryBody: true,
},
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,20 @@ export type FileFindInput = {
export type FileFindOutput = { location: LocationPublicRef; data: Array<FileSystemEntry> }
export type FileWriteInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly path: string
}["location"]
readonly path: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly path: string
}["path"]
readonly payload: globalThis.Uint8Array
}
export type FileWriteOutput = { location: LocationPublicRef; data: FileSystemWrite }
export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+19 -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 } from "@opencode/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -15,6 +15,13 @@ export const ReadInput = Schema.Struct({
})
export type ReadInput = typeof ReadInput.Type
export const WriteInput = Schema.Struct({
/** Absolute, or relative to the location directory. */
path: Schema.String,
data: Schema.Uint8Array,
})
export type WriteInput = typeof WriteInput.Type
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("FileSystem.NotFoundError", {
path: RelativePath,
}) {}
@@ -33,7 +40,7 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export { FindInput }
export { FindInput, Write }
export const DEFAULT_SEARCH_LIMIT = 100
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
@@ -62,6 +69,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 +152,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, input.data).pipe(Effect.orDie)
return Write.make({ path: AbsolutePath.make(target) })
}),
})
}),
)
+28 -8
View File
@@ -585,11 +585,11 @@ function groupShapeTypeName(group: Group, endpoint: Endpoint) {
function assertPromiseEndpoint(endpoint: Endpoint) {
const name = `${endpoint.group}.${endpoint.endpoint.identifier}`
const payload = endpoint.payloads[0]
const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast)
if (
payload !== undefined &&
(payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json"
) {
const payloadEncoding =
payload === undefined
? undefined
: (resolveHttpApiEncoding(payload.ast)?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded"))
if (payloadEncoding !== undefined && payloadEncoding !== "Json" && payloadEncoding !== "Uint8Array") {
throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` })
}
const success = endpoint.successes[0]
@@ -945,7 +945,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
].filter((value): value is string => value !== undefined)
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""}${isBinaryPayload(endpoint) ? ", binaryBody: true" : ""} }`
if (endpoint.operation.success === "stream") {
const success = endpoint.successes[0]
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
@@ -1241,13 +1241,33 @@ function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Gr
"if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {",
)
: sseReady
const binaryBodyReady = endpoints.some(isBinaryPayload)
? replaceOne(
replaceOne(
replaceOne(
binaryReady,
"readonly body?: unknown\n",
"readonly body?: unknown\n readonly binaryBody?: true\n",
),
'if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")',
'if (descriptor.body !== undefined && !headers.has("content-type"))\n headers.set("content-type", descriptor.binaryBody ? "application/octet-stream" : "application/json")',
),
"body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),",
"body:\n descriptor.body === undefined\n ? undefined\n : descriptor.binaryBody\n ? (descriptor.body as RequestInit[\"body\"])\n : JSON.stringify(descriptor.body),",
)
: binaryReady
return usesWildcard
? replaceOne(
binaryReady,
binaryBodyReady,
"function appendQuery(params: URLSearchParams, key: string, value: unknown): void {",
'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {',
)
: binaryReady
: binaryBodyReady
}
function isBinaryPayload(endpoint: Endpoint) {
const payload = endpoint.payloads[0]
return payload !== undefined && resolveHttpApiEncoding(payload.ast)?._tag === "Uint8Array"
}
function replaceOne(input: string, search: string, replacement: string) {
+1 -1
View File
@@ -66,4 +66,4 @@ export const groupNames = {
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "persistentPty.connect"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "persistentPty.connect"])
export const effectOmitEndpoints = new Set(["fs.read", "fs.write", "pty.connect", "persistentPty.connect"])
+23
View File
@@ -13,6 +13,13 @@ const ListQuery = Schema.Struct({
}),
})
const WriteQuery = Schema.Struct({
...LocationQuery.fields,
path: Schema.String.annotate({
description: "An absolute path or a path relative to the requested location. Missing parent directories are created.",
}),
})
const FindQuery = Schema.Struct({
...LocationQuery.fields,
query: FileSystem.FindInput.fields.query,
@@ -65,6 +72,22 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
}),
),
)
.add(
HttpApiEndpoint.post("fs.write", "/api/experimental/fs/write", {
query: WriteQuery,
payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
success: Location.response(FileSystem.Write),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "experimental.fs.write",
summary: "Write file",
description:
"Write the raw request body 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",
+6 -1
View File
@@ -3,7 +3,7 @@ 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 { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
const Changed = ephemeral({
type: "filesystem.changed",
@@ -41,3 +41,8 @@ export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
type: Schema.Literals(["file", "directory"]).pipe(optional),
limit: PositiveInt.pipe(optional),
}) {}
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({ path: ctx.query.path, data: 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"),
}),
)