From 444cd0141361a0ad6fc9189d5e33e3e7b49fbc4b Mon Sep 17 00:00:00 2001 From: Josh Gatto Date: Sun, 2 Aug 2026 19:18:50 +1000 Subject: [PATCH] fix(server): require explicit directory instead of cwd fallback Stop binding directory-less requests to the server's process.cwd() so sessions can never resolve to a shared or server-global worktree. - LocationMiddleware: 400 when x-opencode-directory / location[directory] is absent (packages/server/src/location.ts) - workspace-routing: drop cwd from defaultDirectory(); new MissingDirectory plan returns 400 for directory-less requests - session.create: reject payloads without a location (400) instead of defaulting to process.cwd() - test: assert directory-less session creation returns a v2 public request error (400) --- .../httpapi/middleware/workspace-routing.ts | 20 +++++++++++++--- .../test/server/httpapi-session.test.ts | 19 +++++++++++++++ packages/server/src/handlers/session.ts | 13 ++++++++-- packages/server/src/location.ts | 24 ++++++++++++++----- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 873abd834938..8f6f3b63d4f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -31,6 +31,7 @@ type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ InvalidWorkspace: {} MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID } + MissingDirectory: {} Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID } Remote: { readonly request: HttpServerRequest.HttpServerRequest @@ -83,8 +84,8 @@ function selectedV2WorkspaceID( return workspaceID.value } -function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() +function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string | undefined { + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || undefined } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { @@ -178,8 +179,10 @@ function planRequest( return yield* planWorkspaceRequest(request, url, workspace) } + const directory = session?.directory || defaultDirectory(request, url) + if (directory === undefined) return RequestPlan.MissingDirectory() return RequestPlan.Local({ - directory: session?.directory || defaultDirectory(request, url), + directory, workspaceID: envWorkspaceID ?? workspaceID, }) }) @@ -203,6 +206,17 @@ function routeWorkspace( ), ), MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), + MissingDirectory: () => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + new InvalidRequestError({ + message: "Missing directory: set ?directory= or x-opencode-directory", + kind: "Query", + field: "directory", + }), + { status: 400 }, + ), + ), Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url), Local: ({ directory, workspaceID }) => effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb3309..abad1e6f254a 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -792,6 +792,25 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, ) + it.instance( + "rejects directory-less session creation with a v2 public request error", + () => + Effect.gen(function* () { + const created = yield* request(SessionPaths.create, { + method: "POST", + headers: { "content-type": "application/json" }, + }) + + expect(created.status).toBe(400) + expect(yield* responseJson(created)).toMatchObject({ + _tag: "InvalidRequestError", + kind: "Query", + field: "directory", + }) + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + ) + it.instance( "persists selected workspace id when creating a session", () => diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5b7d354b04fc..3afaab187a7c 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -6,12 +6,12 @@ import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, + InvalidRequestError, MessageNotFoundError, ServiceUnavailableError, SessionNotFoundError, UnknownError, } from "@opencode-ai/protocol/errors" -import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 const DefaultSessionHistoryLimit = 50 @@ -67,12 +67,21 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.create", Effect.fn(function* (ctx) { + if (ctx.payload.location === undefined) { + return yield* Effect.fail( + new InvalidRequestError({ + message: "Missing location: create a session at an explicit location", + kind: "Payload", + field: "location", + }), + ) + } return { data: yield* session.create({ id: ctx.payload.id, agent: ctx.payload.agent, model: ctx.payload.model, - location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + location: ctx.payload.location, }), } }), diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 8ae5aa6e6d08..36ad1df796af 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -2,8 +2,9 @@ import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { Effect, Layer } from "effect" -import { HttpServerRequest } from "effect/unstable/http" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" export type LocationServices = Layer.Success> @@ -26,12 +27,12 @@ export function response(data: Effect.Effect) { }) } -function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { +function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref | undefined { const query = new URL(request.url, "http://localhost").searchParams const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"] - const directory = - query.get("location[directory]") || - (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd()) + const headerDirectory = request.headers["x-opencode-directory"] + const directory = query.get("location[directory]") || (headerDirectory ? decode(headerDirectory) : undefined) + if (directory === undefined) return undefined return Location.Ref.make({ directory: AbsolutePath.make(directory), workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, @@ -53,7 +54,18 @@ export const layer = Layer.effect( return LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) + const location = ref(request) + if (location === undefined) { + return HttpServerResponse.jsonUnsafe( + new InvalidRequestError({ + message: "Missing directory: set x-opencode-directory or location[directory]", + kind: "Query", + field: "location[directory]", + }), + { status: 400 }, + ) + } + return yield* effect.pipe(Effect.provide(locations.get(location))) }), ) }),