From 9db5890ce5e50ac3aa98c747acc72a10af4fc29c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 16:50:54 -0400 Subject: [PATCH] Refactor HttpApi workspace routing and proxy boundaries (#25006) --- packages/opencode/AGENTS.md | 1 + packages/opencode/src/server/proxy-util.ts | 50 +++++ packages/opencode/src/server/proxy.ts | 120 ++++------ .../routes/instance/httpapi/groups/config.ts | 6 +- .../instance/httpapi/groups/experimental.ts | 6 +- .../routes/instance/httpapi/groups/file.ts | 6 +- .../instance/httpapi/groups/instance.ts | 6 +- .../routes/instance/httpapi/groups/mcp.ts | 6 +- .../instance/httpapi/groups/permission.ts | 6 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 6 +- .../instance/httpapi/groups/question.ts | 6 +- .../routes/instance/httpapi/groups/session.ts | 6 +- .../routes/instance/httpapi/groups/sync.ts | 6 +- .../routes/instance/httpapi/groups/tui.ts | 6 +- .../instance/httpapi/groups/workspace.ts | 6 +- .../instance/httpapi/instance-context.ts | 212 ------------------ .../{auth.ts => middleware/authorization.ts} | 0 .../httpapi/middleware/instance-context.ts | 55 +++++ .../instance/httpapi/middleware/proxy.ts | 86 +++++++ .../httpapi/middleware/workspace-routing.ts | 212 ++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 20 +- .../test/server/httpapi-workspace.test.ts | 194 +++++++++++++--- .../opencode/test/server/proxy-util.test.ts | 113 ++++++++++ .../test/server/workspace-proxy.test.ts | 93 ++++++++ .../test/server/workspace-routing.test.ts | 85 +++++++ 27 files changed, 980 insertions(+), 345 deletions(-) create mode 100644 packages/opencode/src/server/proxy-util.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/instance-context.ts rename packages/opencode/src/server/routes/instance/httpapi/{auth.ts => middleware/authorization.ts} (100%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts create mode 100644 packages/opencode/test/server/proxy-util.test.ts create mode 100644 packages/opencode/test/server/workspace-proxy.test.ts create mode 100644 packages/opencode/test/server/workspace-routing.test.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index d7fb844f0d..2a39b6c144 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -78,6 +78,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples. - Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. - `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. - Use `Effect.callback` for callback-based APIs. +- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. ## Module conventions diff --git a/packages/opencode/src/server/proxy-util.ts b/packages/opencode/src/server/proxy-util.ts new file mode 100644 index 0000000000..43d6efb2f9 --- /dev/null +++ b/packages/opencode/src/server/proxy-util.ts @@ -0,0 +1,50 @@ +const hop = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", +]) + +function sanitize(out: Headers) { + for (const key of hop) out.delete(key) + out.delete("accept-encoding") + out.delete("x-opencode-directory") + out.delete("x-opencode-workspace") +} + +export function headers(input: Request | HeadersInit | Record, extra?: HeadersInit) { + const raw = input instanceof Request ? input.headers : input + const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record)) + sanitize(out) + if (!extra) return out + for (const [key, value] of new Headers(extra).entries()) { + out.set(key, value) + } + return out +} + +export function websocketProtocols(input: Request | Record) { + const value = input instanceof Request + ? input.headers.get("sec-websocket-protocol") + : input["sec-websocket-protocol"] + if (!value) return [] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + +export function websocketTargetURL(url: string | URL) { + const next = new URL(url) + if (next.protocol === "http:") next.protocol = "ws:" + if (next.protocol === "https:") next.protocol = "wss:" + return next.toString() +} + +export * as ProxyUtil from "./proxy-util" diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index f93150020d..8541d39f49 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -4,51 +4,12 @@ import * as Log from "@opencode-ai/core/util/log" import * as Fence from "./fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" - -const hop = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "host", -]) +import { ProxyUtil } from "./proxy-util" +import { Effect, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" type Msg = string | ArrayBuffer | Uint8Array -function headers(req: Request, extra?: HeadersInit) { - const out = new Headers(req.headers) - for (const key of hop) out.delete(key) - out.delete("accept-encoding") - out.delete("x-opencode-directory") - out.delete("x-opencode-workspace") - if (!extra) return out - for (const [key, value] of new Headers(extra).entries()) { - out.set(key, value) - } - return out -} - -export function websocketProtocols(req: Request) { - const value = req.headers.get("sec-websocket-protocol") - if (!value) return [] - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean) -} - -export function websocketTargetURL(url: string | URL) { - const next = new URL(url) - if (next.protocol === "http:") next.protocol = "ws:" - if (next.protocol === "https:") next.protocol = "wss:" - return next.toString() -} - function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) { if (data instanceof Blob) { return data.arrayBuffer().then((x) => ws.send(x)) @@ -69,7 +30,7 @@ const app = (upgrade: UpgradeWebSocket) => ws.close(1011, "missing proxy target") return } - remote = new WebSocket(url, websocketProtocols(c.req.raw)) + remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw)) remote.binaryType = "arraybuffer" remote.onopen = () => { for (const item of queue) remote?.send(item) @@ -103,40 +64,57 @@ const app = (upgrade: UpgradeWebSocket) => const log = Log.create({ service: "server-proxy" }) -export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { +function statusText(response: unknown) { + return (response as { source?: Response }).source?.statusText +} + +export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { if (!Workspace.isSyncing(workspaceID)) { - return new Response(`broken sync connection for workspace: ${workspaceID}`, { - status: 503, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) + return Effect.succeed( + new Response(`broken sync connection for workspace: ${workspaceID}`, { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }), + ) } - return fetch( - new Request(url, { - method: req.method, - headers: headers(req, extra), - body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, - redirect: "manual", - signal: req.signal, - }), - ).then((res) => { - const sync = Fence.parse(res.headers) - const next = new Headers(res.headers) + return Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.make(req.method as never)(url, { + headers: ProxyUtil.headers(req, extra), + body: + req.method === "GET" || req.method === "HEAD" + ? HttpBody.empty + : HttpBody.raw(req.body, { + contentType: req.headers.get("content-type") ?? undefined, + contentLength: req.headers.get("content-length") + ? Number(req.headers.get("content-length")) + : undefined, + }), + }), + ) + const next = new Headers(response.headers as HeadersInit) + const sync = Fence.parse(next) next.delete("content-encoding") next.delete("content-length") - const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve() - - return done.then(async () => { - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: next, - }) + if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal)) + const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) + return new Response(body, { + status: response.status, + statusText: statusText(response), + headers: next, }) - }) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))), + ) +} + +export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { + return Effect.runPromise(httpEffect(url, extra, req, workspaceID)) } export function websocket( @@ -150,7 +128,7 @@ export function websocket( proxy.pathname = "/__workspace_ws" proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", websocketTargetURL(target)) + next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target)) for (const [key, value] of new Headers(extra).entries()) { next.set(key, value) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index 4ff406e2a4..fa77785a9b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,8 +1,9 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -48,6 +49,7 @@ export const ConfigApi = HttpApi.make("config") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 2a562b46b3..e4a86ca139 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -6,8 +6,9 @@ import { Worktree } from "@/worktree" import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -201,6 +202,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 3a4f3df7f9..b950adb383 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -3,8 +3,9 @@ import { Ripgrep } from "@/file/ripgrep" import { LSP } from "@/lsp/lsp" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ @@ -108,6 +109,7 @@ export const FileApi = HttpApi.make("file") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index cc450f448c..463ea1ae4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -6,8 +6,9 @@ import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -130,6 +131,7 @@ export const InstanceApi = HttpApi.make("instance") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index 149f8814a9..e9caf0cd9d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -2,8 +2,9 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -131,6 +132,7 @@ export const McpApi = HttpApi.make("mcp") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index e06c98d9ef..22c4d6f6d3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -2,8 +2,9 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -45,6 +46,7 @@ export const PermissionApi = HttpApi.make("permission") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 92019866e9..1a2084547d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -2,8 +2,9 @@ import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -64,6 +65,7 @@ export const ProjectApi = HttpApi.make("project") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 56dace0e5e..4a9bbffc54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -3,8 +3,9 @@ import { Provider } from "@/provider/provider" import { ProviderID } from "@/provider/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -63,6 +64,7 @@ export const ProviderApi = HttpApi.make("provider") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index eb71526fb3..d54bda4a84 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -2,8 +2,9 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/pty" @@ -95,6 +96,7 @@ export const PtyApi = HttpApi.make("pty") ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de249823b7..de2d4fca8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -2,8 +2,9 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -57,6 +58,7 @@ export const QuestionApi = HttpApi.make("question") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 5a388f1876..bc26a9e597 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -13,8 +13,9 @@ import { Snapshot } from "@/snapshot" import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/session" @@ -417,6 +418,7 @@ export const SessionApi = HttpApi.make("session") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 1d9b08d9cb..58d30b4c78 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,8 +1,9 @@ import { NonNegativeInt } from "@/util/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -79,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 49ba05c2d5..efe73d95d1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,8 +1,9 @@ import { TuiEvent } from "@/cli/cmd/tui/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/tui" @@ -184,6 +185,7 @@ export const TuiApi = HttpApi.make("tui") ) .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index ab5f08bb17..268e84f2ec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -3,8 +3,9 @@ import { WorkspaceAdaptorEntry } from "@/control-plane/types" import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -90,6 +91,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ) .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts deleted file mode 100644 index 42e973020d..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { getAdaptor } from "@/control-plane/adaptors" -import { WorkspaceID } from "@/control-plane/schema" -import type { Target } from "@/control-plane/types" -import { Workspace } from "@/control-plane/workspace" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import { Session } from "@/session/session" -import { ServerProxy } from "@/server/proxy" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" -import { Filesystem } from "@/util/filesystem" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Effect, Layer } from "effect" -import type { unhandled } from "effect/Types" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" - -type HandlerEffect = Effect.Effect - -export class InstanceContextMiddleware extends HttpApiMiddleware.Service< - InstanceContextMiddleware, - { - requires: Session.Service - } ->()("@opencode/ExperimentalHttpApiInstanceContext") {} - -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -function currentDirectory() { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - -function sourceRequest(request: HttpServerRequest.HttpServerRequest) { - if (request.source instanceof Request) return request.source - return new Request(new URL(request.originalUrl, "http://localhost"), { - method: request.method, - headers: request.headers as HeadersInit, - }) -} - -function requestHeaders(request: HttpServerRequest.HttpServerRequest) { - return sourceRequest(request).headers -} - -function writeSocket( - write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, - data: unknown, -) { - if (data instanceof Blob) { - void data - .arrayBuffer() - .then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) - return - } - if (typeof data === "string" || data instanceof Uint8Array) { - Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void))) - return - } - if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void))) -} - -function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) { - return Effect.gen(function* () { - const source = sourceRequest(request) - const socket = yield* Effect.orDie(request.upgrade) - const write = yield* socket.writer - const queue: Array = [] - const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source)) - remote.binaryType = "arraybuffer" - remote.onopen = () => { - for (const item of queue) remote.send(item) - queue.length = 0 - } - remote.onmessage = (event) => writeSocket(write, event.data) - remote.onerror = () => - Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) - remote.onclose = (event) => - Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) - - yield* socket - .runRaw((message) => { - const data = typeof message === "string" ? message : message.slice() - if (remote.readyState === WebSocket.OPEN) { - remote.send(data) - return - } - queue.push(data) - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring(Effect.sync(() => remote.close())), - Effect.orDie, - ) - return HttpServerResponse.empty() - }) -} - -function proxyRemote( - request: HttpServerRequest.HttpServerRequest, - workspace: Workspace.Info, - target: Extract, - requestURL: URL, -) { - const url = workspaceProxyURL(target.url, requestURL) - const source = sourceRequest(request) - if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) - return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe( - Effect.map(HttpServerResponse.raw), - ) -} - -function requestContext() { - return Effect.withFiber((fiber) => - Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)), - ) -} - -function provideRequestContext( - effect: HandlerEffect, - request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, -) { - return Effect.gen(function* () { - const url = new URL(request.url, "http://localhost") - const headers = requestHeaders(request) - const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined - const workspaceParam = url.searchParams.get("workspace") - const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) - const workspace = - workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined - - if (workspaceID && !workspace && !envWorkspaceID) { - return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { - status: 500, - contentType: "text/plain; charset=utf-8", - }) - } - - if ( - workspace && - !isLocalWorkspaceRoute(request.method, url.pathname) && - !url.pathname.startsWith("/console") && - !envWorkspaceID - ) { - const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) - if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspace.id), - ) - } - - const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory() - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID), - ) - }) -} - -function provideInstanceContext(effect: HandlerEffect) { - return Effect.gen(function* () { - const request = yield* requestContext() - const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost")) - const session = sessionID - ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( - Effect.catch(() => Effect.succeed(undefined)), - Effect.catchDefect(() => Effect.succeed(undefined)), - ) - : undefined - return yield* provideRequestContext(effect, request, session?.workspaceID) - }) -} - -export const instanceContextLayer = Layer.succeed( - InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), -) - -export const instanceRouterLayer = HttpRouter.middleware()( - Effect.succeed((effect) => - requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), - ), -).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/auth.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/auth.ts rename to packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts new file mode 100644 index 0000000000..c80f1caeb6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -0,0 +1,55 @@ +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import type { InstanceContext } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import { Effect, Layer } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { WorkspaceRouteContext } from "./workspace-routing" + +export class InstanceContextMiddleware extends HttpApiMiddleware.Service< + InstanceContextMiddleware, + { + requires: WorkspaceRouteContext + } +>()("@opencode/ExperimentalHttpApiInstanceContext") {} + +function decode(input: string): string { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function makeInstanceContext(directory: string): Effect.Effect { + return Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(directory)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) +} + +function provideInstanceContext( + effect: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + const ctx = yield* makeInstanceContext(route.directory) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, route.workspaceID), + ) + }) +} + +export const instanceContextLayer = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), +) + +export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts new file mode 100644 index 0000000000..a2153ce714 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -0,0 +1,86 @@ +import { ProxyUtil } from "@/server/proxy-util" +import { Effect, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" + +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 + const len = request.headers["content-length"] + return HttpBody.raw(webSource(request)?.body ?? null, { + contentType: request.headers["content-type"], + contentLength: len ? Number(len) : undefined, + }) +} + +export function websocket( + request: HttpServerRequest.HttpServerRequest, + target: string | URL, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const inbound = yield* Effect.orDie(request.upgrade) + const outbound = yield* Socket.makeWebSocket(ProxyUtil.websocketTargetURL(target), { + protocols: ProxyUtil.websocketProtocols(request.headers), + }) + const writeInbound = yield* inbound.writer + const writeOutbound = yield* outbound.writer + + yield* outbound + .runRaw((message) => writeInbound(message)) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", (reason) => + writeInbound(new Socket.CloseEvent(reason.code, reason.closeReason)).pipe(Effect.catch(() => Effect.void)), + ), + Effect.catch(() => writeInbound(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))), + Effect.forkScoped, + ) + + yield* inbound + .runRaw((message) => { + return writeOutbound(typeof message === "string" ? message : message.slice()) + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(writeOutbound(new Socket.CloseEvent()).pipe(Effect.catch(() => Effect.void))), + ) + return HttpServerResponse.empty() + }).pipe(Effect.orDie), + ) +} + +function statusText(response: unknown) { + return (response as { source?: Response }).source?.statusText +} + +export function http( + url: string | URL, + extra: HeadersInit | undefined, + request: HttpServerRequest.HttpServerRequest, +): Effect.Effect { + return Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.make(request.method as never)(url, { + headers: ProxyUtil.headers(request.headers as HeadersInit, extra), + body: requestBody(request), + }), + ) + const headers = new Headers(response.headers as HeadersInit) + headers.delete("content-encoding") + headers.delete("content-length") + + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + statusText: statusText(response), + headers, + }) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))), + ) +} + +export * as HttpApiProxy from "./proxy" 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 new file mode 100644 index 0000000000..4b68242b57 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -0,0 +1,212 @@ +import { getAdaptor } from "@/control-plane/adaptors" +import { WorkspaceID } from "@/control-plane/schema" +import type { Target } from "@/control-plane/types" +import { Workspace } from "@/control-plane/workspace" +import { Instance } from "@/project/instance" +import { Session } from "@/session/session" +import { HttpApiProxy } from "./proxy" +import * as Fence from "@/server/fence" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Context, Data, Effect, Layer } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" + +type RemoteTarget = Extract + +type RequestPlan = Data.TaggedEnum<{ + MissingWorkspace: { readonly workspaceID: WorkspaceID } + Local: { readonly directory: string; readonly workspaceID?: WorkspaceID } + Remote: { + readonly request: HttpServerRequest.HttpServerRequest + readonly workspace: Workspace.Info + readonly target: RemoteTarget + readonly url: URL + } +}> +const RequestPlan = Data.taggedEnum() + +export class WorkspaceRouteContext extends Context.Service()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {} + +export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< + WorkspaceRoutingMiddleware, + { + provides: WorkspaceRouteContext + requires: Session.Service + } +>()("@opencode/ExperimentalHttpApiWorkspaceRouting") {} + +function currentDirectory(): string { + try { + return Instance.directory + } catch { + return process.cwd() + } +} + +function requestURL(request: HttpServerRequest.HttpServerRequest): URL { + return new URL(request.url, "http://localhost") +} + +function configuredWorkspaceID(): WorkspaceID | undefined { + return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined +} + +function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined { + const workspaceParam = url.searchParams.get("workspace") + return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) +} + +function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory() +} + +function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { + return isLocalWorkspaceRoute(request.method, url.pathname) || url.pathname.startsWith("/console") +} + +function resolveWorkspace( + id: WorkspaceID | undefined, + envWorkspaceID: WorkspaceID | undefined, +): Effect.Effect { + if (!id || envWorkspaceID) return Effect.void + return Effect.promise(() => Workspace.get(id)) +} + +function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse { + return HttpServerResponse.text(`Workspace not found: ${id}`, { + status: 500, + contentType: "text/plain; charset=utf-8", + }) +} + +function resolveTarget(workspace: Workspace.Info): Effect.Effect { + return Effect.gen(function* () { + const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + }) +} + +function proxyRemote( + request: HttpServerRequest.HttpServerRequest, + workspace: Workspace.Info, + target: RemoteTarget, + url: URL, +): Effect.Effect { + return Effect.gen(function* () { + const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id)) + if (!syncing) { + return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { + status: 503, + contentType: "text/plain; charset=utf-8", + }) + } + const proxyURL = workspaceProxyURL(target.url, url) + const headers = request.headers as Record + if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) + const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) + const sync = Fence.parse(new Headers(response.headers)) + if (sync) yield* Effect.promise(() => Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined)) + return response + }) +} + +function planWorkspaceRequest( + request: HttpServerRequest.HttpServerRequest, + url: URL, + workspace: Workspace.Info, +): Effect.Effect { + return Effect.gen(function* () { + const target = yield* resolveTarget(workspace) + if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url }) + return RequestPlan.Local({ directory: target.directory, workspaceID: workspace.id }) + }) +} + +function planRequest( + request: HttpServerRequest.HttpServerRequest, + sessionWorkspaceID?: WorkspaceID, +): Effect.Effect { + return Effect.gen(function* () { + const url = requestURL(request) + const envWorkspaceID = configuredWorkspaceID() + const workspaceID = selectedWorkspaceID(url, sessionWorkspaceID) + const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) + + if (workspaceID && workspace === undefined && !envWorkspaceID) { + return RequestPlan.MissingWorkspace({ workspaceID }) + } + + if (workspace !== undefined && !envWorkspaceID && !shouldStayOnControlPlane(request, url)) { + return yield* planWorkspaceRequest(request, url, workspace) + } + + return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID }) + }) +} + +function routeWorkspace( + effect: Effect.Effect, + plan: RequestPlan, +): Effect.Effect { + return RequestPlan.$match(plan, { + MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), + Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), + Local: ({ directory, workspaceID }) => + effect.pipe( + Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID })), + ), + }) +} + +function routeWorkspaceRequest( + effect: Effect.Effect, + request: HttpServerRequest.HttpServerRequest, + sessionWorkspaceID?: WorkspaceID, +): Effect.Effect { + return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan)) +} + +function routeHttpApiWorkspace( + effect: Effect.Effect, +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor +> { + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const sessionID = getWorkspaceRouteSessionID(requestURL(request)) + const session = sessionID + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) + : undefined + return yield* routeWorkspaceRequest(effect, request, session?.workspaceID) + }) +} + +export const workspaceRoutingLayer = Layer.effect( + WorkspaceRoutingMiddleware, + Effect.gen(function* () { + const makeWebSocket = yield* Socket.WebSocketConstructor + return WorkspaceRoutingMiddleware.of((effect) => + routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)), + ) + }), +) + +export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()( + Effect.gen(function* () { + const makeWebSocket = yield* Socket.WebSocketConstructor + return (effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* routeWorkspaceRequest(effect, request).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + ) + }) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 2f4bde9183..144ba0c632 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,7 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -31,7 +32,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { InstanceHttpApi, RootHttpApi } from "./api" -import { authorizationLayer } from "./auth" +import { authorizationLayer } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -49,7 +50,8 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { workspaceHandlers } from "./handlers/workspace" -import { instanceContextLayer, instanceRouterLayer } from "./instance-context" +import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" +import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" @@ -86,9 +88,19 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) +const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( + Layer.provide( + instanceRouterMiddleware.combine(workspaceRouterMiddleware).layer.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + ), + ), +) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( - Layer.provide([authorizationLayer, instanceContextLayer]), + Layer.provide([ + authorizationLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), + instanceContextLayer, + ]), ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 5ce531dcc2..74dfbaef86 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" @@ -14,6 +14,7 @@ import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspaceRef } from "../../src/effect/instance-ref" void Log.init({ print: false }) @@ -27,8 +28,13 @@ function request(path: string, directory: string, init: RequestInit = {}) { return Server.Default().app.request(path, { ...init, headers }) } -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +function runSession(fx: Effect.Effect, workspaceID?: Workspace.Info["id"]) { + return Effect.runPromise( + fx.pipe( + workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect, + Effect.provide(Session.defaultLayer), + ), + ) } function localAdaptor(directory: string): WorkspaceAdaptor { @@ -55,7 +61,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { +function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { return { name: "Remote Test", description: "Create a remote test workspace", @@ -74,20 +80,51 @@ function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { return { type: "remote" as const, url, + headers, } }, } } -function eventStreamResponse() { - return new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { - "content-type": "text/event-stream", +type ProxiedRequest = { + url: string + method: string + headers: Record + body: string +} + +function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise) { + return Bun.serve({ + port: 0, + async fetch(request) { + return handler({ + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text(), + }) }, }) } +function eventStreamResponse() { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'), + ) + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ) +} + afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces @@ -97,6 +134,8 @@ afterEach(async () => { }) describe("workspace HttpApi", () => { + test.todo("proxies remote workspace websocket through real Effect listener", () => {}) + test("serves read endpoints", async () => { await using tmp = await tmpdir({ git: true }) @@ -191,25 +230,33 @@ describe("workspace HttpApi", () => { } }) - test("proxies remote workspace HTTP requests", async () => { + test("proxies remote workspace HTTP requests with sanitized forwarding", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true }) - const proxied: string[] = [] - const rawFetch = globalThis.fetch - spyOn(globalThis, "fetch").mockImplementation( - Object.assign( - async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - proxied.push(url.toString()) - return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") }) - }, + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return new Response( + JSON.stringify({ + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }), { - preconnect: rawFetch.preconnect?.bind(rawFetch), + status: 201, + statusText: "Created", + headers: { + "content-length": "999", + "content-type": "application/json", + "x-remote": "yes", + }, }, - ) as typeof globalThis.fetch, - ) + ) + }) const workspace = await Instance.provide({ directory: tmp.path, @@ -217,7 +264,9 @@ describe("workspace HttpApi", () => { registerAdaptor( Instance.project.id, "remote-target", - remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"), + remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + "x-target-auth": "secret", + }), ) return Workspace.create({ type: "remote-target", @@ -228,16 +277,101 @@ describe("workspace HttpApi", () => { }, }) - const url = new URL(`http://localhost${InstancePaths.path}`) + const url = new URL("http://localhost/config") url.searchParams.set("workspace", workspace.id) + url.searchParams.set("keep", "yes") try { - const response = await request(url.toString(), tmp.path) + const response = await request(url.toString(), tmp.path, { + method: "PATCH", + headers: { + "accept-encoding": "br", + "content-type": "application/json", + "x-opencode-workspace": "internal", + }, + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null }) - expect(proxied).toEqual(["https://remote.test/base/path"]) + const responseBody = await response.text() + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) + expect(response.headers.get("content-length")).toBeNull() + expect(response.headers.get("x-remote")).toBe("yes") + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) + const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") + expect(forwarded).toEqual([ + { + url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-target-auth": "secret", + }), + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }, + ]) + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") } finally { + remote.stop(true) + await Workspace.remove(workspace.id) + } + }) + + test("proxies remote workspace requests selected from session ownership", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return Response.json({ proxied: true, path: new URL(request.url).pathname }) + }) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor( + Instance.project.id, + "remote-session-target", + remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + ) + return Workspace.create({ + type: "remote-session-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + const session = await Instance.provide({ + directory: tmp.path, + fn: async () => + runSession( + Session.Service.use((svc) => svc.create()), + workspace.id, + ), + }) + + try { + const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }) + + const responseBody = await response.text() + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + method: "POST", + }), + ]) + } finally { + remote.stop(true) await Workspace.remove(workspace.id) } }) diff --git a/packages/opencode/test/server/proxy-util.test.ts b/packages/opencode/test/server/proxy-util.test.ts new file mode 100644 index 0000000000..d13a06bb31 --- /dev/null +++ b/packages/opencode/test/server/proxy-util.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { ProxyUtil } from "../../src/server/proxy-util" + +describe("ProxyUtil", () => { + describe("websocketTargetURL", () => { + test("converts http to ws", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path")).toBe("ws://example.com/path") + }) + + test("converts https to wss", () => { + expect(ProxyUtil.websocketTargetURL("https://example.com/path")).toBe("wss://example.com/path") + }) + + test("preserves query params", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path?foo=bar")).toBe("ws://example.com/path?foo=bar") + }) + + test("accepts URL objects", () => { + expect(ProxyUtil.websocketTargetURL(new URL("http://localhost:3000/ws"))).toBe("ws://localhost:3000/ws") + }) + }) + + describe("websocketProtocols", () => { + test("returns empty array when no header", () => { + const req = new Request("http://localhost") + expect(ProxyUtil.websocketProtocols(req)).toEqual([]) + }) + + test("parses single protocol", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws"]) + }) + + test("parses multiple protocols", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws, graphql-transport-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws", "graphql-transport-ws"]) + }) + + test("trims whitespace and filters empty", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": " proto1 , , proto2 " }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["proto1", "proto2"]) + }) + }) + + describe("headers", () => { + test("strips hop-by-hop headers", () => { + const req = new Request("http://localhost", { + headers: { + connection: "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + "content-type": "application/json", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("connection")).toBeNull() + expect(result.get("keep-alive")).toBeNull() + expect(result.get("transfer-encoding")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + }) + + test("strips opencode-specific headers", () => { + const req = new Request("http://localhost", { + headers: { + "x-opencode-directory": "/home/user/project", + "x-opencode-workspace": "ws_123", + "accept-encoding": "gzip", + "x-custom": "keep", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("x-opencode-directory")).toBeNull() + expect(result.get("x-opencode-workspace")).toBeNull() + expect(result.get("accept-encoding")).toBeNull() + expect(result.get("x-custom")).toBe("keep") + }) + + test("merges extra headers", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json" }, + }) + const result = ProxyUtil.headers(req, { "x-auth": "token", "content-type": "text/plain" }) + expect(result.get("x-auth")).toBe("token") + expect(result.get("content-type")).toBe("text/plain") + }) + + test("returns original headers when no extra", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json", "x-foo": "bar" }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-foo")).toBe("bar") + }) + + test("accepts plain object (HeadersInit) as input", () => { + const result = ProxyUtil.headers( + { "content-type": "application/json", connection: "keep-alive", "x-custom": "val" }, + { "x-extra": "added" }, + ) + expect(result.get("connection")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-custom")).toBe("val") + expect(result.get("x-extra")).toBe("added") + }) + }) +}) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts new file mode 100644 index 0000000000..14f5bd06d6 --- /dev/null +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -0,0 +1,93 @@ +import { NodeHttpServer } from "@effect/platform-node" +import Http from "node:http" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" +import { testEffect } from "../lib/effect" + +function serverUrl() { + return Effect.gen(function* () { + return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + }) +} + +const testServerLayer = NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }) +const it = testEffect(testServerLayer) + +describe("HttpApi workspace proxy", () => { + it.live("proxies HTTP request and returns streamed response with status and headers", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const body = yield* req.text + return yield* HttpServerResponse.json({ path: req.url, method: req.method, body }, { + status: 201, + headers: { + "content-encoding": "identity", + "content-length": "999", + "x-remote": "yes", + }, + }) + }), + ) + const url = yield* serverUrl() + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), + ) + const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) + + expect(response.status).toBe(201) + const client = HttpServerResponse.toClientResponse(response) + expect(yield* client.json).toEqual({ + path: "/session/abc?keep=yes", + method: "POST", + body: "request-body", + }) + expect(response.headers["x-remote"]).toBe("yes") + expect(response.headers["content-encoding"]).toBeUndefined() + expect(response.headers["content-length"]).toBeUndefined() + }), + ) + + it.live("returns 500 when remote is unreachable", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) + const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request) + + expect(response.status).toBe(500) + }), + ) + + it.live("strips opencode-internal headers and merges extra headers", () => + Effect.gen(function* () { + let forwarded: Record = {} + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + forwarded = req.headers + return HttpServerResponse.empty() + }), + ) + const url = yield* serverUrl() + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/test", { + headers: { + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "ws_123", + "x-custom": "preserved", + }, + }), + ) + yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request) + + expect(forwarded["x-opencode-directory"]).toBeUndefined() + expect(forwarded["x-opencode-workspace"]).toBeUndefined() + expect(forwarded["x-custom"]).toBe("preserved") + expect(forwarded["x-injected"]).toBe("extra") + }), + ) +}) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts new file mode 100644 index 0000000000..22c44a6dff --- /dev/null +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { SessionID } from "../../src/session/schema" + +describe("isLocalWorkspaceRoute", () => { + test("GET /session is local", () => { + expect(isLocalWorkspaceRoute("GET", "/session")).toBe(true) + }) + + test("GET /session/ses_abc is local (prefix match)", () => { + expect(isLocalWorkspaceRoute("GET", "/session/ses_abc")).toBe(true) + }) + + test("POST /session is not local (method mismatch)", () => { + expect(isLocalWorkspaceRoute("POST", "/session")).toBe(false) + }) + + test("/session/status is forwarded regardless of method", () => { + expect(isLocalWorkspaceRoute("GET", "/session/status")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/status")).toBe(false) + }) + + test("unrecognized paths are not local", () => { + expect(isLocalWorkspaceRoute("GET", "/config")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/ses_abc/message")).toBe(false) + }) +}) + +describe("getWorkspaceRouteSessionID", () => { + test("extracts session ID from path", () => { + const url = new URL("http://localhost/session/ses_abc123/message") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_abc123")) + }) + + test("extracts session ID without trailing path", () => { + const url = new URL("http://localhost/session/ses_xyz") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz")) + }) + + test("returns null for /session/status", () => { + const url = new URL("http://localhost/session/status") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for non-session paths", () => { + const url = new URL("http://localhost/config") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for bare /session path", () => { + const url = new URL("http://localhost/session") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) +}) + +describe("workspaceProxyURL", () => { + test("appends request path to target", () => { + const result = workspaceProxyURL("http://remote:8080/base", new URL("http://localhost/config")) + expect(result.toString()).toBe("http://remote:8080/base/config") + }) + + test("strips trailing slash on target before appending", () => { + const result = workspaceProxyURL("http://remote:8080/base/", new URL("http://localhost/session/abc")) + expect(result.pathname).toBe("/base/session/abc") + }) + + test("preserves query params from request but removes workspace", () => { + const url = new URL("http://localhost/config?workspace=ws_123&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("workspace")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + + test("preserves hash from request", () => { + const url = new URL("http://localhost/page#section") + const result = workspaceProxyURL("http://remote:8080", url) + expect(result.hash).toBe("#section") + }) + + test("works with URL object as target", () => { + const target = new URL("http://remote:3000/api") + const result = workspaceProxyURL(target, new URL("http://localhost/users")) + expect(result.toString()).toBe("http://remote:3000/api/users") + }) +})