瀏覽代碼

Refactor HttpApi workspace routing and proxy boundaries (#25006)

Kit Langton 4 月之前
父節點
當前提交
9db5890ce5
共有 27 個文件被更改,包括 980 次插入345 次删除
  1. 1 0
      packages/opencode/AGENTS.md
  2. 50 0
      packages/opencode/src/server/proxy-util.ts
  3. 49 71
      packages/opencode/src/server/proxy.ts
  4. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/config.ts
  5. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
  6. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
  7. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts
  8. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
  9. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
  10. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/project.ts
  11. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts
  12. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts
  13. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/question.ts
  14. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  15. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts
  16. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts
  17. 4 2
      packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
  18. 0 212
      packages/opencode/src/server/routes/instance/httpapi/instance-context.ts
  19. 0 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts
  20. 55 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts
  21. 86 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts
  22. 212 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
  23. 16 4
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  24. 164 30
      packages/opencode/test/server/httpapi-workspace.test.ts
  25. 113 0
      packages/opencode/test/server/proxy-util.test.ts
  26. 93 0
      packages/opencode/test/server/workspace-proxy.test.ts
  27. 85 0
      packages/opencode/test/server/workspace-routing.test.ts

+ 1 - 0
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

+ 50 - 0
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<string, string>, extra?: HeadersInit) {
+  const raw = input instanceof Request ? input.headers : input
+  const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record<string, string>))
+  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<string, string | undefined>) {
+  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"

+ 49 - 71
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)
   }

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 4 - 2
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(

+ 0 - 212
packages/opencode/src/server/routes/instance/httpapi/instance-context.ts

@@ -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<HttpServerResponse.HttpServerResponse, unhandled, never>
-
-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<void, unknown>,
-  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<string | Uint8Array> = []
-    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<Target, { type: "remote" }>,
-  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<HttpServerRequest.HttpServerRequest, never>((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

+ 0 - 0
packages/opencode/src/server/routes/instance/httpapi/auth.ts → packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts


+ 55 - 0
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<InstanceContext> {
+  return Effect.promise(() =>
+    Instance.provide({
+      directory: Filesystem.resolve(decode(directory)),
+      init: () => AppRuntime.runPromise(InstanceBootstrap),
+      fn: () => Instance.current,
+    }),
+  )
+}
+
+function provideInstanceContext<E>(
+  effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
+): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
+  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))

+ 86 - 0
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<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor> {
+  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<HttpServerResponse.HttpServerResponse> {
+  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"

+ 212 - 0
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<Target, { type: "remote" }>
+
+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<RequestPlan>()
+
+export class WorkspaceRouteContext extends Context.Service<WorkspaceRouteContext, {
+  readonly directory: string
+  readonly workspaceID?: WorkspaceID
+}>()("@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<Workspace.Info | void> {
+  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<Target> {
+  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<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor> {
+  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<string, string>
+    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<RequestPlan> {
+  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<RequestPlan> {
+  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<E>(
+  effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
+  plan: RequestPlan,
+): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
+  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<E>(
+  effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
+  request: HttpServerRequest.HttpServerRequest,
+  sessionWorkspaceID?: WorkspaceID,
+): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
+  return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan))
+}
+
+function routeHttpApiWorkspace<E>(
+  effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
+): 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),
+        )
+      })
+  }),
+)

+ 16 - 4
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(

+ 164 - 30
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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
-  return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>, 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<string, string>
+  body: string
+}
+
+function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
+  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)
     }
   })

+ 113 - 0
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")
+    })
+  })
+})

+ 93 - 0
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<string, string> = {}
+      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")
+    }),
+  )
+})

+ 85 - 0
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")
+  })
+})