httpapi-workspace-routing.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  2. import { Flag } from "@opencode-ai/core/flag/flag"
  3. import { describe, expect } from "bun:test"
  4. import { Context, Effect, Layer, Queue } from "effect"
  5. import {
  6. HttpClient,
  7. HttpClientRequest,
  8. HttpRouter,
  9. HttpServer,
  10. HttpServerRequest,
  11. HttpServerResponse,
  12. } from "effect/unstable/http"
  13. import * as Socket from "effect/unstable/socket/Socket"
  14. import Http from "node:http"
  15. import { mkdir } from "node:fs/promises"
  16. import path from "node:path"
  17. import { registerAdaptor } from "../../src/control-plane/adaptors"
  18. import { WorkspaceID } from "../../src/control-plane/schema"
  19. import type { WorkspaceAdaptor } from "../../src/control-plane/types"
  20. import { Workspace } from "../../src/control-plane/workspace"
  21. import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
  22. import { Project } from "../../src/project/project"
  23. import {
  24. WorkspaceRouteContext,
  25. workspaceRouterMiddleware,
  26. } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
  27. import { Database } from "../../src/storage/db"
  28. import { resetDatabase } from "../fixture/db"
  29. import { tmpdirScoped } from "../fixture/fixture"
  30. import { testEffect } from "../lib/effect"
  31. const testStateLayer = Layer.effectDiscard(
  32. Effect.gen(function* () {
  33. const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  34. yield* Effect.promise(() => resetDatabase())
  35. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  36. yield* Effect.addFinalizer(() =>
  37. Effect.promise(async () => {
  38. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  39. await resetDatabase()
  40. }),
  41. )
  42. }),
  43. )
  44. const it = testEffect(
  45. Layer.mergeAll(
  46. testStateLayer,
  47. NodeHttpServer.layerTest,
  48. NodeServices.layer,
  49. Project.defaultLayer,
  50. Socket.layerWebSocketConstructorGlobal,
  51. ),
  52. )
  53. type ProxiedRequest = {
  54. url: string
  55. method: string
  56. headers: Record<string, string>
  57. }
  58. type TestHandler<E, R> = (
  59. request: HttpServerRequest.HttpServerRequest,
  60. ) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
  61. const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
  62. Layer.provide(Socket.layerWebSocketConstructorGlobal),
  63. )
  64. const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
  65. const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost")
  66. const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
  67. Effect.gen(function* () {
  68. const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
  69. const server = Context.get(context, HttpServer.HttpServer)
  70. yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
  71. return HttpServer.formatAddress(server.address)
  72. })
  73. const localAdaptor = (directory: string): WorkspaceAdaptor => ({
  74. name: "Local Test",
  75. description: "Create a local test workspace",
  76. configure: (info) => ({ ...info, name: "local-test", directory }),
  77. create: async () => {
  78. await mkdir(directory, { recursive: true })
  79. },
  80. async remove() {},
  81. target: () => ({ type: "local" as const, directory }),
  82. })
  83. const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
  84. name: "Remote Test",
  85. description: "Create a remote test workspace",
  86. configure: (info) => ({ ...info, name: "remote-test", directory }),
  87. create: async () => {
  88. await mkdir(directory, { recursive: true })
  89. },
  90. async remove() {},
  91. target: () => ({ type: "remote" as const, url, headers }),
  92. })
  93. const eventStreamResponse = () =>
  94. HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', {
  95. contentType: "text/event-stream",
  96. })
  97. const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
  98. const url = requestURL(request)
  99. if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse())
  100. if (url.pathname === "/base/sync/history") return HttpServerResponse.json([])
  101. return undefined
  102. }
  103. const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
  104. Effect.acquireRelease(
  105. Effect.promise(async () => {
  106. registerAdaptor(input.projectID, input.type, input.adaptor)
  107. return Workspace.create({
  108. type: input.type,
  109. branch: null,
  110. extra: null,
  111. projectID: input.projectID,
  112. })
  113. }),
  114. (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
  115. )
  116. const createRemoteWorkspace = (input: {
  117. dir: string
  118. projectID: Project.Info["id"]
  119. type: string
  120. url: string
  121. headers?: HeadersInit
  122. }) =>
  123. // Workspace.create starts the remote sync loop. The test upstream exposes
  124. // /global/event and /sync/history so middleware proxying sees the remote
  125. // workspace as active, just like production would.
  126. createWorkspace({
  127. projectID: input.projectID,
  128. type: input.type,
  129. adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
  130. })
  131. const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
  132. createWorkspace({
  133. projectID: input.projectID,
  134. type: input.type,
  135. adaptor: localAdaptor(input.directory),
  136. })
  137. const insertRemoteWorkspaceWithoutSync = (input: {
  138. dir: string
  139. projectID: Project.Info["id"]
  140. type: string
  141. url: string
  142. }) =>
  143. Effect.sync(() => {
  144. const id = WorkspaceID.ascending()
  145. registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
  146. Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
  147. return id
  148. })
  149. const startRemoteWorkspaceHttpServer = <E, R>(
  150. handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
  151. ) =>
  152. listenAdditionalServer((request) =>
  153. Effect.gen(function* () {
  154. // Remote workspaces run a sync loop against their target server. These
  155. // bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
  156. // everything else is the request being proxied by the middleware.
  157. const sync = syncResponse(request)
  158. if (sync) return yield* sync
  159. return yield* handler({ url: request.url, method: request.method, headers: request.headers })
  160. }),
  161. )
  162. const listenRemoteWebSocket = () =>
  163. listenAdditionalServer((request) => {
  164. const sync = syncResponse(request)
  165. if (sync) return sync
  166. if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
  167. return echoWebSocket(request)
  168. })
  169. const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
  170. Effect.gen(function* () {
  171. const socket = yield* Effect.orDie(request.upgrade)
  172. const write = yield* socket.writer
  173. yield* socket
  174. .runRaw((message) => write(`echo:${String(message)}`), {
  175. onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
  176. Effect.catch(() => Effect.void),
  177. ),
  178. })
  179. .pipe(Effect.catch(() => Effect.void))
  180. return HttpServerResponse.empty()
  181. })
  182. const serveRouteContextProbe = HttpRouter.add(
  183. "GET",
  184. "/probe",
  185. Effect.gen(function* () {
  186. // The fake route exposes the context installed by the middleware, so tests
  187. // can assert routing decisions without pulling in the production API tree.
  188. const route = yield* WorkspaceRouteContext
  189. return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
  190. }),
  191. ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
  192. describe("HttpApi workspace routing middleware", () => {
  193. it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
  194. Effect.gen(function* () {
  195. const dir = yield* tmpdirScoped({ git: true })
  196. const project = yield* Project.use.fromDirectory(dir)
  197. let forwarded: ProxiedRequest | undefined
  198. // This starts a second HTTP server that stands in for the opencode server
  199. // backing a remote workspace. The client below still calls the local test
  200. // server; only the middleware should call this server.
  201. const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
  202. forwarded = request
  203. const url = requestURL(request)
  204. return HttpServerResponse.json(
  205. {
  206. proxied: true,
  207. path: url.pathname,
  208. keep: url.searchParams.get("keep"),
  209. workspace: url.searchParams.get("workspace"),
  210. },
  211. { status: 201, headers: { "x-remote": "yes" } },
  212. )
  213. })
  214. // The adaptor target tells the middleware where to proxy selected remote
  215. // workspace requests. Appending /probe to this base should produce
  216. // `${remoteUrl}/base/probe` on the fake remote server above.
  217. const workspace = yield* createRemoteWorkspace({
  218. dir,
  219. projectID: project.project.id,
  220. type: "remote-http-target",
  221. url: `${remoteUrl}/base`,
  222. headers: { "x-target-auth": "secret" },
  223. })
  224. // The local /probe handler should not run. Selecting a remote workspace
  225. // should make the middleware call HttpApiProxy.http instead.
  226. yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
  227. Layer.provide(workspaceRoutingTestLayer),
  228. HttpRouter.serve,
  229. Layer.build,
  230. )
  231. const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
  232. HttpClientRequest.setHeaders({
  233. "content-type": "application/json",
  234. "x-opencode-directory": "/secret/path",
  235. "x-opencode-workspace": "internal",
  236. }),
  237. HttpClient.execute,
  238. )
  239. expect(response.status).toBe(201)
  240. expect(response.headers["x-remote"]).toBe("yes")
  241. expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
  242. const forwardedURL = forwarded ? requestURL(forwarded) : undefined
  243. // These assertions are the routing contract: append the original path to
  244. // the remote base URL, preserve normal query params, and remove workspace.
  245. expect(forwardedURL?.pathname).toBe("/base/probe")
  246. expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
  247. expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
  248. expect(forwarded?.method).toBe("PATCH")
  249. expect(forwarded?.headers["content-type"]).toBe("application/json")
  250. expect(forwarded?.headers["x-target-auth"]).toBe("secret")
  251. expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
  252. expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
  253. }),
  254. )
  255. it.live("returns 503 when a remote workspace is not actively syncing", () =>
  256. Effect.gen(function* () {
  257. const dir = yield* tmpdirScoped({ git: true })
  258. const project = yield* Project.use.fromDirectory(dir)
  259. const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
  260. dir,
  261. projectID: project.project.id,
  262. type: "remote-not-syncing",
  263. url: "http://127.0.0.1:1/base",
  264. })
  265. yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
  266. Layer.provide(workspaceRoutingTestLayer),
  267. HttpRouter.serve,
  268. Layer.build,
  269. )
  270. const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
  271. expect(response.status).toBe(503)
  272. expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
  273. }),
  274. )
  275. it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
  276. Effect.gen(function* () {
  277. const dir = yield* tmpdirScoped({ git: true })
  278. const project = yield* Project.use.fromDirectory(dir)
  279. const remoteUrl = yield* listenRemoteWebSocket()
  280. const workspace = yield* createRemoteWorkspace({
  281. dir,
  282. projectID: project.project.id,
  283. type: "remote-websocket-target",
  284. url: `${remoteUrl}/base`,
  285. })
  286. // The client connects to the local test server. The middleware should
  287. // detect the WebSocket upgrade and proxy it to the remote /base/probe.
  288. yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
  289. Layer.provide(workspaceRoutingTestLayer),
  290. HttpRouter.serve,
  291. Layer.build,
  292. )
  293. const socket = yield* Socket.makeWebSocket(
  294. `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
  295. {
  296. closeCodeIsError: () => false,
  297. protocols: "chat",
  298. },
  299. )
  300. const messages = yield* Queue.unbounded<string>()
  301. yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
  302. const write = yield* socket.writer
  303. expect(yield* Queue.take(messages)).toBe("protocol:chat")
  304. yield* write("hello")
  305. expect(yield* Queue.take(messages)).toBe("echo:hello")
  306. }),
  307. )
  308. it.live("returns a missing workspace response for unknown workspace ids", () =>
  309. Effect.gen(function* () {
  310. const workspaceID = WorkspaceID.ascending("wrk_missing")
  311. // If the middleware resolves the workspace first, this handler is never
  312. // reached and the response should be the middleware error response.
  313. yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
  314. Layer.provide(workspaceRoutingTestLayer),
  315. HttpRouter.serve,
  316. Layer.build,
  317. )
  318. const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
  319. expect(response.status).toBe(500)
  320. expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
  321. }),
  322. )
  323. it.live("keeps control-plane routes local even when workspace is selected", () =>
  324. Effect.gen(function* () {
  325. const dir = yield* tmpdirScoped({ git: true })
  326. const project = yield* Project.use.fromDirectory(dir)
  327. const workspaceDir = path.join(dir, ".workspace-local")
  328. const workspace = yield* createLocalWorkspace({
  329. projectID: project.project.id,
  330. type: "control-plane-target",
  331. directory: workspaceDir,
  332. })
  333. // GET /session is a control-plane route: it lists sessions for the main
  334. // process and should not be redirected into the selected workspace target.
  335. yield* HttpRouter.add(
  336. "GET",
  337. "/session",
  338. Effect.gen(function* () {
  339. const route = yield* WorkspaceRouteContext
  340. return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
  341. }),
  342. ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
  343. const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
  344. expect(response.status).toBe(200)
  345. expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
  346. }),
  347. )
  348. it.live("uses directory query/header fallback when no workspace is selected", () =>
  349. Effect.gen(function* () {
  350. const dir = yield* tmpdirScoped()
  351. const queryDir = path.join(dir, "query-target")
  352. const headerDir = path.join(dir, "header-target")
  353. yield* serveRouteContextProbe
  354. // Without a selected workspace, the middleware falls back to request
  355. // directory hints before using the process cwd.
  356. const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
  357. const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
  358. HttpClientRequest.setHeader("x-opencode-directory", headerDir),
  359. HttpClient.execute,
  360. )
  361. expect(queryResponse.status).toBe(200)
  362. expect(yield* queryResponse.json).toEqual({ directory: queryDir })
  363. expect(headerResponse.status).toBe(200)
  364. expect(yield* headerResponse.json).toEqual({ directory: headerDir })
  365. }),
  366. )
  367. it.live("routes local workspace requests through WorkspaceRouteContext", () =>
  368. Effect.gen(function* () {
  369. const dir = yield* tmpdirScoped({ git: true })
  370. const project = yield* Project.use.fromDirectory(dir)
  371. const workspaceDir = path.join(dir, ".workspace-local")
  372. const workspace = yield* createLocalWorkspace({
  373. projectID: project.project.id,
  374. type: "local-target",
  375. directory: workspaceDir,
  376. })
  377. yield* serveRouteContextProbe
  378. // /probe is not a control-plane route, so selecting a local workspace
  379. // should swap the route context to the workspace target directory.
  380. const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
  381. expect(response.status).toBe(200)
  382. expect(yield* response.json).toEqual({
  383. directory: workspaceDir,
  384. workspaceID: workspace.id,
  385. })
  386. }),
  387. )
  388. })