httpapi-workspace-routing.test.ts 18 KB

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