summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-29 16:50:54 -0400
committerGitHub <[email protected]>2026-04-29 16:50:54 -0400
commit9db5890ce5e50ac3aa98c747acc72a10af4fc29c (patch)
treea97dffe75ec265cc57cc5311c73a8ae45139a5f5
parent293877cb7e60610b4b0c25992dbab2169c6f614e (diff)
downloadopencode-9db5890ce5e50ac3aa98c747acc72a10af4fc29c.tar.gz
opencode-9db5890ce5e50ac3aa98c747acc72a10af4fc29c.zip
Refactor HttpApi workspace routing and proxy boundaries (#25006)
-rw-r--r--packages/opencode/AGENTS.md1
-rw-r--r--packages/opencode/src/server/proxy-util.ts50
-rw-r--r--packages/opencode/src/server/proxy.ts120
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/config.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/file.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/project.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/question.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/session.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/instance-context.ts212
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/auth.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts55
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts86
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts212
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts20
-rw-r--r--packages/opencode/test/server/httpapi-workspace.test.ts194
-rw-r--r--packages/opencode/test/server/proxy-util.test.ts113
-rw-r--r--packages/opencode/test/server/workspace-proxy.test.ts93
-rw-r--r--packages/opencode/test/server/workspace-routing.test.ts85
27 files changed, 980 insertions, 345 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index d7fb844f0..2a39b6c14 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 000000000..43d6efb2f
--- /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<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"
diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts
index f93150020..8541d39f4 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 4ff406e2a..fa77785a9 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 2a562b46b..e4a86ca13 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 3a4f3df7f..b950adb38 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 cc450f448..463ea1ae4 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 149f8814a..e9caf0cd9 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 e06c98d9e..22c4d6f6d 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 92019866e..1a2084547 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 56dace0e5..4a9bbffc5 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 eb71526fb..d54bda4a8 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 de249823b..de2d4fca8 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 5a388f187..bc26a9e59 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 1d9b08d9c..58d30b4c7 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 49ba05c2d..efe73d95d 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 ab5f08bb1..268e84f2e 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 42e973020..000000000
--- 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<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
diff --git a/packages/opencode/src/server/routes/instance/httpapi/auth.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts
index 2fe196b56..2fe196b56 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/auth.ts
+++ b/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 000000000..c80f1caeb
--- /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<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))
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 000000000..a2153ce71
--- /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<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"
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 000000000..4b68242b5
--- /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<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),
+ )
+ })
+ }),
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 2f4bde918..144ba0c63 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 5ce531dcc..74dfbaef8 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<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)
}
})
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 000000000..d13a06bb3
--- /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 000000000..14f5bd06d
--- /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<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")
+ }),
+ )
+})
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 000000000..22c44a6df
--- /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")
+ })
+})