| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- 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 {
- FetchHttpClient,
- 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 { registerAdapter } from "../../src/control-plane/adapters"
- import { WorkspaceID } from "../../src/control-plane/schema"
- import type { WorkspaceAdapter } 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 { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
- 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,
- Workspace.defaultLayer,
- Socket.layerWebSocketConstructorGlobal,
- ),
- )
- type ProxiedRequest = {
- url: string
- method: string
- headers: Record<string, string>
- }
- type TestHandler<E, R> = (
- request: HttpServerRequest.HttpServerRequest,
- ) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
- const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
- Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
- )
- 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 = <E, R>(handler: TestHandler<E, R>) =>
- 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 localAdapter = (directory: string): WorkspaceAdapter => ({
- 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 remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({
- 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; adapter: WorkspaceAdapter }) =>
- Effect.acquireRelease(
- Effect.gen(function* () {
- registerAdapter(input.projectID, input.type, input.adapter)
- const workspace = yield* Workspace.Service
- return yield* workspace.create({
- type: input.type,
- branch: null,
- extra: null,
- projectID: input.projectID,
- })
- }),
- (info) => Workspace.Service.use((workspace) => workspace.remove(info.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,
- adapter: remoteAdapter(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,
- adapter: localAdapter(input.directory),
- })
- const insertRemoteWorkspaceWithoutSync = (input: {
- dir: string
- projectID: Project.Info["id"]
- type: string
- url: string
- }) =>
- Effect.sync(() => {
- const id = WorkspaceID.ascending()
- registerAdapter(input.projectID, input.type, remoteAdapter(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 = <E, R>(
- handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
- ) =>
- 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 adapter 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<string>()
- 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("keeps workspace control 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: "workspace-control-plane-target",
- directory: workspaceDir,
- })
- // Workspace CRUD/status routes manage the control plane itself. Selecting
- // a workspace should preserve the selected id for handlers, but must not
- // swap the route context to the workspace target directory.
- yield* HttpRouter.add(
- "GET",
- WorkspacePaths.list,
- 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(`${WorkspacePaths.list}?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,
- })
- }),
- )
- })
|