From 7bafbb309a7ca28c82f3dcdf7b06b0beb8fd6b64 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 27 May 2026 21:54:36 -0400 Subject: [PATCH] fix(opencode): forward remote workspace request bodies (#29458) --- .../instance/httpapi/middleware/proxy.ts | 10 ++-------- .../server/httpapi-workspace-routing.test.ts | 18 +++++++++++++++--- .../test/server/httpapi-workspace.test.ts | 10 ++++++++++ .../test/server/workspace-proxy.test.ts | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 230f5b105b..e5362f8cbe 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR import * as Socket from "effect/unstable/socket/Socket" import { WebSocketTracker } from "../websocket-tracker" -function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { - return request.source instanceof Request ? request.source : undefined -} - function requestBody(request: HttpServerRequest.HttpServerRequest) { if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + if (request.source instanceof Request && request.source.body === null) return HttpBody.empty const len = request.headers["content-length"] - return HttpBody.raw(webSource(request)?.body ?? null, { - contentType: request.headers["content-type"], - contentLength: len ? Number(len) : undefined, - }) + return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined) } export function websocket( diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index bc7074e23c..50a400674a 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,6 +1,6 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue, Ref, Schema } from "effect" +import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect" import { FetchHttpClient, HttpClient, @@ -64,6 +64,7 @@ type ProxiedRequest = { url: string method: string headers: Record + body: string } type TestHandler = ( @@ -181,7 +182,12 @@ const startRemoteWorkspaceHttpServer = ( // everything else is the request being proxied by the middleware. const sync = syncResponse(request) if (sync) return yield* sync - return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + return yield* handler({ + url: request.url, + method: request.method, + headers: request.headers, + body: yield* request.text, + }) }), ) @@ -285,13 +291,18 @@ describe("HttpApi workspace routing middleware", () => { // should make the middleware call HttpApiProxy.http instead. yield* serveProbe + const body = '{"title":"Remote workspace request"}' const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( HttpClientRequest.setHeaders({ - "content-type": "application/json", "x-opencode-directory": "/secret/path", "x-opencode-workspace": "internal", }), + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')), + { contentType: "application/json" }, + ), HttpClient.execute, + Effect.timeout("2 seconds"), ) expect(response.status).toBe(201) @@ -304,6 +315,7 @@ describe("HttpApi workspace routing middleware", () => { expect(forwardedURL?.searchParams.get("keep")).toBe("yes") expect(forwardedURL?.searchParams.get("workspace")).toBeNull() expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.body).toBe(body) expect(forwarded?.headers["content-type"]).toBe("application/json") expect(forwarded?.headers["x-target-auth"]).toBe("secret") expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index e1c6e5701d..35d6cb313e 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -479,6 +479,16 @@ describe("workspace HttpApi", () => { method: "POST", }), ]) + + const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" }) + expect(aborted.status).toBe(200) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`, + method: "POST", + body: "", + }), + ]) } finally { void remote.stop(true) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 732f2560a2..869570f62d 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -112,6 +112,22 @@ describe("HttpApi workspace proxy", () => { }), ) + it.live("proxies bodyless Web mutation requests as an empty body", () => + Effect.gen(function* () { + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { + return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text }) + }), + ) + const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" })) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request) + + expect(response.status).toBe(200) + expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" }) + }), + ) + it.live("strips opencode-internal headers and merges extra headers", () => Effect.gen(function* () { let forwarded: Record = {}