From 9052e8a1bac3a546c3bd06eb2f550fa8cea3b4fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 21:08:03 -0400 Subject: [PATCH] test: cover HttpApi workspace routing middleware (#25027) --- packages/opencode/src/effect/service-use.ts | 38 ++ packages/opencode/src/project/project.ts | 5 +- .../server/httpapi-workspace-routing.test.ts | 437 ++++++++++++++++++ .../test/server/workspace-proxy.test.ts | 2 +- 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/effect/service-use.ts create mode 100644 packages/opencode/test/server/httpapi-workspace-routing.test.ts diff --git a/packages/opencode/src/effect/service-use.ts b/packages/opencode/src/effect/service-use.ts new file mode 100644 index 0000000000..a93cdecbb1 --- /dev/null +++ b/packages/opencode/src/effect/service-use.ts @@ -0,0 +1,38 @@ +import { Context, Effect } from "effect" + +type EffectMethod = (...args: ReadonlyArray) => Effect.Effect + +type ServiceUse = { + readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends ( + ...args: infer Args + ) => infer Return + ? Args extends ReadonlyArray + ? Return extends Effect.Effect + ? (...args: Args) => Effect.Effect + : never + : never + : never +} + +export const serviceUse = (tag: Context.Service) => { + // This is the only dynamic boundary: TypeScript knows the accessor shape, + // but Proxy property names are runtime values. + const access = new Proxy( + {}, + { + get: (_, key) => { + if (typeof key !== "string") return undefined + return (...args: unknown[]) => + tag.use((service) => { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. + const method = service[key as keyof Shape] + if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. + return (method as (...args: unknown[]) => Effect.Effect)(...args) + }) + }, + }, + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily. + return access as ServiceUse +} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 4229112a83..86208a60cd 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" +import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) @@ -178,7 +179,7 @@ export const layer: Layer.Layer< const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( Effect.map((x) => x.trim()), - Effect.map(ProjectID.make), + Effect.map((x) => ProjectID.make(x)), Effect.catch(() => Effect.void), ) }) @@ -485,6 +486,8 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const use = serviceUse(Service) + export function list() { return Database.use((db) => db diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts new file mode 100644 index 0000000000..6d06499224 --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -0,0 +1,437 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import Http from "node:http" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { registerAdaptor } from "../../src/control-plane/adaptors" +import { WorkspaceID } from "../../src/control-plane/schema" +import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { Project } from "../../src/project/project" +import { + WorkspaceRouteContext, + workspaceRouterMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { Database } from "../../src/storage/db" +import { resetDatabase } from "../fixture/db" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await resetDatabase() + }), + ) + }), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + Project.defaultLayer, + Socket.layerWebSocketConstructorGlobal, + ), +) + +type ProxiedRequest = { + url: string + method: string + headers: Record +} + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), +) + +const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost") + +const listenAdditionalServer = (handler: TestHandler) => + Effect.gen(function* () { + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) + +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({ + name: "Remote Test", + description: "Create a remote test workspace", + configure: (info) => ({ ...info, name: "remote-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "remote" as const, url, headers }), +}) + +const eventStreamResponse = () => + HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', { + contentType: "text/event-stream", + }) + +const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { + const url = requestURL(request) + if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse()) + if (url.pathname === "/base/sync/history") return HttpServerResponse.json([]) + return undefined +} + +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => + Effect.acquireRelease( + Effect.promise(async () => { + registerAdaptor(input.projectID, input.type, input.adaptor) + return Workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), + ) + +const createRemoteWorkspace = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string + headers?: HeadersInit +}) => + // Workspace.create starts the remote sync loop. The test upstream exposes + // /global/event and /sync/history so middleware proxying sees the remote + // workspace as active, just like production would. + createWorkspace({ + projectID: input.projectID, + type: input.type, + adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers), + }) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + createWorkspace({ + projectID: input.projectID, + type: input.type, + adaptor: localAdaptor(input.directory), + }) + +const insertRemoteWorkspaceWithoutSync = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string +}) => + Effect.sync(() => { + const id = WorkspaceID.ascending() + registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url)) + Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) + return id + }) + +const startRemoteWorkspaceHttpServer = ( + handler: (request: ProxiedRequest) => Effect.Effect, +) => + listenAdditionalServer((request) => + Effect.gen(function* () { + // Remote workspaces run a sync loop against their target server. These + // bootstrap routes make Workspace.isSyncing(...) true for proxy tests; + // everything else is the request being proxied by the middleware. + const sync = syncResponse(request) + if (sync) return yield* sync + return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + }), + ) + +const listenRemoteWebSocket = () => + listenAdditionalServer((request) => { + const sync = syncResponse(request) + if (sync) return sync + if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + return echoWebSocket(request) + }) + +const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) + +const serveRouteContextProbe = HttpRouter.add( + "GET", + "/probe", + Effect.gen(function* () { + // The fake route exposes the context installed by the middleware, so tests + // can assert routing decisions without pulling in the production API tree. + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), +).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + +describe("HttpApi workspace routing middleware", () => { + it.live("proxies remote workspace HTTP requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let forwarded: ProxiedRequest | undefined + + // This starts a second HTTP server that stands in for the opencode server + // backing a remote workspace. The client below still calls the local test + // server; only the middleware should call this server. + const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { + forwarded = request + const url = requestURL(request) + return HttpServerResponse.json( + { + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }, + { status: 201, headers: { "x-remote": "yes" } }, + ) + }) + // The adaptor target tells the middleware where to proxy selected remote + // workspace requests. Appending /probe to this base should produce + // `${remoteUrl}/base/probe` on the fake remote server above. + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-http-target", + url: `${remoteUrl}/base`, + headers: { "x-target-auth": "secret" }, + }) + + // The local /probe handler should not run. Selecting a remote workspace + // should make the middleware call HttpApiProxy.http instead. + yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( + HttpClientRequest.setHeaders({ + "content-type": "application/json", + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "internal", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(201) + expect(response.headers["x-remote"]).toBe("yes") + expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null }) + const forwardedURL = forwarded ? requestURL(forwarded) : undefined + // These assertions are the routing contract: append the original path to + // the remote base URL, preserve normal query params, and remove workspace. + expect(forwardedURL?.pathname).toBe("/base/probe") + expect(forwardedURL?.searchParams.get("keep")).toBe("yes") + expect(forwardedURL?.searchParams.get("workspace")).toBeNull() + expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.headers["content-type"]).toBe("application/json") + expect(forwarded?.headers["x-target-auth"]).toBe("secret") + expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() + expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined() + }), + ) + + it.live("returns 503 when a remote workspace is not actively syncing", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = yield* insertRemoteWorkspaceWithoutSync({ + dir, + projectID: project.project.id, + type: "remote-not-syncing", + url: "http://127.0.0.1:1/base", + }) + + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(503) + expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`) + }), + ) + + it.live("proxies remote workspace WebSocket requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const remoteUrl = yield* listenRemoteWebSocket() + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-websocket-target", + url: `${remoteUrl}/base`, + }) + + // The client connects to the local test server. The middleware should + // detect the WebSocket upgrade and proxy it to the remote /base/probe. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`, + { + closeCodeIsError: () => false, + protocols: "chat", + }, + ) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) + + it.live("returns a missing workspace response for unknown workspace ids", () => + Effect.gen(function* () { + const workspaceID = WorkspaceID.ascending("wrk_missing") + // If the middleware resolves the workspace first, this handler is never + // reached and the response should be the middleware error response. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(500) + expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`) + }), + ) + + it.live("keeps control-plane routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "control-plane-target", + directory: workspaceDir, + }) + + // GET /session is a control-plane route: it lists sessions for the main + // process and should not be redirected into the selected workspace target. + yield* HttpRouter.add( + "GET", + "/session", + Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), + ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("uses directory query/header fallback when no workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const queryDir = path.join(dir, "query-target") + const headerDir = path.join(dir, "header-target") + yield* serveRouteContextProbe + + // Without a selected workspace, the middleware falls back to request + // directory hints before using the process cwd. + const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`) + const headerResponse = yield* HttpClientRequest.get("/probe").pipe( + HttpClientRequest.setHeader("x-opencode-directory", headerDir), + HttpClient.execute, + ) + + expect(queryResponse.status).toBe(200) + expect(yield* queryResponse.json).toEqual({ directory: queryDir }) + expect(headerResponse.status).toBe(200) + expect(yield* headerResponse.json).toEqual({ directory: headerDir }) + }), + ) + + it.live("routes local workspace requests through WorkspaceRouteContext", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "local-target", + directory: workspaceDir, + }) + + yield* serveRouteContextProbe + + // /probe is not a control-plane route, so selecting a local workspace + // should swap the route context to the workspace target directory. + const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) +}) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index a39a33b8c6..3e52ade638 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -46,7 +46,7 @@ function echoWebSocket(request: HttpServerRequest.HttpServerRequest) { // The upstream announces the negotiated protocol, then echoes every // received frame. The assertions use those messages to prove proxy flow. yield* socket - .runRaw((message) => write(`echo:${message}`), { + .runRaw((message) => write(`echo:${String(message)}`), { onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( Effect.catch(() => Effect.void), ),