httpapi-workspace-routing.test.ts 20 KB

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