From ab7696e9b940088a671db272ca2254c4ee7a106e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 11 Aug 2026 13:45:45 +0200 Subject: [PATCH] fix(server): allow file read/write for absolute paths outside workspace The Preview tab's raw editor needs to read and save files that live outside the project directory (e.g. files in ~/.julia/dev/Altissimo that the session edited). The traversal guard now only blocks relative-path escapes; absolute paths are served directly from disk via fs/promises. --- .../routes/instance/httpapi/handlers/file.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index ab38b41e1..5703aea5a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -6,6 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Effect, Layer, Option } from "effect" +import * as fs from "fs/promises" import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -96,8 +97,17 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { const directory = (yield* InstanceState.context).directory const file = path.resolve(directory, ctx.query.path) - if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) + const isAbsolute = path.isAbsolute(ctx.query.path) + // Allow absolute paths (the session may touch files outside the workspace); + // relative paths must stay within the workspace. + if (!isAbsolute && !FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) if (!(yield* FSUtil.Service.use((fs) => fs.existsSafe(file)))) return { type: "text" as const, content: "" } + if (isAbsolute && !FSUtil.contains(directory, file)) { + // Read directly from disk for absolute paths outside the workspace + const buf = yield* Effect.tryPromise(() => fs.readFile(file)).pipe(Effect.orDie) + const text = new TextDecoder("utf-8", { fatal: true }).decode(buf) + return { type: "text" as const, content: text.trim() } + } return yield* filesystem( FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })), ).pipe( @@ -131,7 +141,16 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const write = Effect.fn("FileHttpApi.write")(function* (ctx: { payload: { path: string; content: string } }) { const directory = (yield* InstanceState.context).directory const file = path.resolve(directory, ctx.payload.path) - if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) + const isAbsolute = path.isAbsolute(ctx.payload.path) + // Allow absolute paths (the session may touch files outside the workspace); + // relative paths must stay within the workspace. + if (!isAbsolute && !FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) + if (isAbsolute && !FSUtil.contains(directory, file)) { + // Write directly for absolute paths outside the workspace + yield* Effect.tryPromise(() => fs.mkdir(path.dirname(file), { recursive: true })).pipe(Effect.orDie) + yield* Effect.tryPromise(() => fs.writeFile(file, ctx.payload.content, "utf-8")).pipe(Effect.orDie) + return { ok: true as const } + } yield* FSUtil.Service.use((fs) => fs.writeWithDirs(file, ctx.payload.content)).pipe(Effect.orDie) return { ok: true as const } })