httpapi-workspace.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import { afterEach, describe, expect, mock } from "bun:test"
  2. import { NodeServices } from "@effect/platform-node"
  3. import { mkdir } from "node:fs/promises"
  4. import path from "node:path"
  5. import { Effect, Layer } from "effect"
  6. import { Flag } from "@opencode-ai/core/flag/flag"
  7. import { registerAdapter } from "../../src/control-plane/adapters"
  8. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  9. import { Workspace } from "../../src/control-plane/workspace"
  10. import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
  11. import { Session } from "@/session/session"
  12. import * as Log from "@opencode-ai/core/util/log"
  13. import { Server } from "../../src/server/server"
  14. import { resetDatabase } from "../fixture/db"
  15. import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
  16. import { InstanceBootstrap } from "../../src/project/bootstrap"
  17. import { InstanceStore } from "../../src/project/instance-store"
  18. import { Project } from "../../src/project/project"
  19. import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
  20. import { WorkspaceRef } from "../../src/effect/instance-ref"
  21. import { testEffect } from "../lib/effect"
  22. void Log.init({ print: false })
  23. const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  24. const workspaceLayer = Workspace.defaultLayer.pipe(
  25. Layer.provide(InstanceStore.defaultLayer),
  26. Layer.provide(InstanceBootstrap.defaultLayer),
  27. )
  28. const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
  29. function request(path: string, directory: string, init: RequestInit = {}) {
  30. return Effect.promise(() => {
  31. const headers = new Headers(init.headers)
  32. headers.set("x-opencode-directory", directory)
  33. return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
  34. })
  35. }
  36. function localAdapter(directory: string): WorkspaceAdapter {
  37. return {
  38. name: "Local Test",
  39. description: "Create a local test workspace",
  40. configure(info) {
  41. return {
  42. ...info,
  43. name: "local-test",
  44. directory,
  45. }
  46. },
  47. async create() {
  48. await mkdir(directory, { recursive: true })
  49. },
  50. async remove() {},
  51. target() {
  52. return {
  53. type: "local" as const,
  54. directory,
  55. }
  56. },
  57. }
  58. }
  59. function listedAdapter(directory: string, type: string): WorkspaceAdapter {
  60. return {
  61. name: "Listed Test",
  62. description: "List a local test workspace",
  63. configure(info) {
  64. return { ...info, name: "unused", directory }
  65. },
  66. async create() {},
  67. async remove() {},
  68. list(context) {
  69. return [
  70. {
  71. type,
  72. name: "listed-test",
  73. branch: "listed/main",
  74. directory,
  75. extra: { listed: true },
  76. projectID: context?.instance?.project.id ?? missingAdapterContext(),
  77. },
  78. ]
  79. },
  80. target() {
  81. return {
  82. type: "local" as const,
  83. directory,
  84. }
  85. },
  86. }
  87. }
  88. function missingAdapterContext(): never {
  89. throw new Error("missing workspace adapter context")
  90. }
  91. function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
  92. return {
  93. name: "Remote Test",
  94. description: "Create a remote test workspace",
  95. configure(info) {
  96. return {
  97. ...info,
  98. name: "remote-test",
  99. directory,
  100. }
  101. },
  102. async create() {
  103. await mkdir(directory, { recursive: true })
  104. },
  105. async remove() {},
  106. target() {
  107. return {
  108. type: "remote" as const,
  109. url,
  110. headers,
  111. }
  112. },
  113. }
  114. }
  115. type ProxiedRequest = {
  116. url: string
  117. method: string
  118. headers: Record<string, string>
  119. body: string
  120. }
  121. function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
  122. return Bun.serve({
  123. port: 0,
  124. async fetch(request) {
  125. return handler({
  126. url: request.url,
  127. method: request.method,
  128. headers: Object.fromEntries(request.headers.entries()),
  129. body: await request.text(),
  130. })
  131. },
  132. })
  133. }
  134. function eventStreamResponse() {
  135. return new Response(
  136. new ReadableStream({
  137. start(controller) {
  138. controller.enqueue(
  139. new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'),
  140. )
  141. },
  142. }),
  143. {
  144. status: 200,
  145. headers: {
  146. "content-type": "text/event-stream",
  147. },
  148. },
  149. )
  150. }
  151. afterEach(async () => {
  152. mock.restore()
  153. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  154. await disposeAllInstances()
  155. await resetDatabase()
  156. })
  157. describe("workspace HttpApi", () => {
  158. it.live("serves read endpoints", () =>
  159. Effect.gen(function* () {
  160. const dir = yield* tmpdirScoped({ git: true })
  161. const [adapters, workspaces, status] = yield* Effect.all([
  162. request(WorkspacePaths.adapters, dir),
  163. request(WorkspacePaths.list, dir),
  164. request(WorkspacePaths.status, dir),
  165. ])
  166. expect(adapters.status).toBe(200)
  167. expect(yield* Effect.promise(() => adapters.json())).toContainEqual({
  168. type: "worktree",
  169. name: "Worktree",
  170. description: "Create a git worktree",
  171. })
  172. expect(workspaces.status).toBe(200)
  173. expect(yield* Effect.promise(() => workspaces.json())).toEqual([])
  174. expect(status.status).toBe(200)
  175. expect(yield* Effect.promise(() => status.json())).toEqual([])
  176. }),
  177. )
  178. it.live("serves mutation endpoints", () =>
  179. Effect.gen(function* () {
  180. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  181. const dir = yield* tmpdirScoped({ git: true })
  182. const project = yield* Project.use.fromDirectory(dir)
  183. registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
  184. const created = yield* request(WorkspacePaths.list, dir, {
  185. method: "POST",
  186. headers: { "content-type": "application/json" },
  187. body: JSON.stringify({ type: "local-test", branch: null }),
  188. })
  189. expect(created.status).toBe(200)
  190. const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
  191. expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
  192. const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
  193. const warped = yield* request(WorkspacePaths.warp, dir, {
  194. method: "POST",
  195. headers: { "content-type": "application/json" },
  196. body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
  197. })
  198. expect(warped.status).toBe(204)
  199. const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  200. expect(removed.status).toBe(200)
  201. expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id })
  202. const listed = yield* request(WorkspacePaths.list, dir)
  203. expect(listed.status).toBe(200)
  204. expect(yield* Effect.promise(() => listed.json())).toEqual([])
  205. }),
  206. )
  207. it.live("serves list sync endpoint", () =>
  208. Effect.gen(function* () {
  209. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  210. const dir = yield* tmpdirScoped({ git: true })
  211. const project = yield* Project.use.fromDirectory(dir)
  212. const type = `listed-${Math.random().toString(36).slice(2)}`
  213. registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
  214. const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
  215. expect(response.status).toBe(204)
  216. const listed = yield* request(WorkspacePaths.list, dir)
  217. expect(yield* Effect.promise(() => listed.json())).toMatchObject([
  218. {
  219. type,
  220. name: "listed-test",
  221. branch: "listed/main",
  222. directory: path.join(dir, ".listed"),
  223. extra: { listed: true },
  224. },
  225. ])
  226. }),
  227. )
  228. it.live("creates workspace with the TUI payload shape", () =>
  229. Effect.gen(function* () {
  230. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  231. const dir = yield* tmpdirScoped({ git: true })
  232. const project = yield* Project.use.fromDirectory(dir)
  233. registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
  234. const created = yield* request(WorkspacePaths.list, dir, {
  235. method: "POST",
  236. headers: { "content-type": "application/json" },
  237. body: JSON.stringify({ type: "local-test", branch: null }),
  238. })
  239. expect(created.status).toBe(200)
  240. expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
  241. type: "local-test",
  242. name: "local-test",
  243. })
  244. }),
  245. )
  246. it.live("creates a real git worktree workspace via the builtin adapter", () =>
  247. Effect.gen(function* () {
  248. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  249. const dir = yield* tmpdirScoped({ git: true })
  250. const created = yield* request(WorkspacePaths.list, dir, {
  251. method: "POST",
  252. headers: { "content-type": "application/json" },
  253. body: JSON.stringify({ type: "worktree", branch: null }),
  254. })
  255. const body = yield* Effect.promise(() => created.text())
  256. expect({ status: created.status, body }).toMatchObject({ status: 200 })
  257. const workspace = JSON.parse(body) as Workspace.Info
  258. expect(workspace).toMatchObject({ type: "worktree" })
  259. }),
  260. )
  261. it.live("routes local workspace requests through the workspace target directory", () =>
  262. Effect.gen(function* () {
  263. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  264. const dir = yield* tmpdirScoped({ git: true })
  265. const workspaceDir = path.join(dir, ".workspace-local")
  266. const project = yield* Project.use.fromDirectory(dir)
  267. registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
  268. const created = yield* request(WorkspacePaths.list, dir, {
  269. method: "POST",
  270. headers: { "content-type": "application/json" },
  271. body: JSON.stringify({ type: "local-target", branch: null }),
  272. })
  273. const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
  274. const url = new URL(`http://localhost${InstancePaths.path}`)
  275. url.searchParams.set("workspace", workspace.id)
  276. const response = yield* request(url.toString(), dir)
  277. expect(response.status).toBe(200)
  278. expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir })
  279. yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  280. }),
  281. )
  282. it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
  283. Effect.gen(function* () {
  284. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  285. const dir = yield* tmpdirScoped({ git: true })
  286. const proxied: ProxiedRequest[] = []
  287. const remote = listenRemoteHttp((request) => {
  288. proxied.push(request)
  289. const url = new URL(request.url)
  290. if (url.pathname === "/base/global/event") return eventStreamResponse()
  291. if (url.pathname === "/base/sync/history") return Response.json([])
  292. return new Response(
  293. JSON.stringify({
  294. proxied: true,
  295. path: url.pathname,
  296. keep: url.searchParams.get("keep"),
  297. workspace: url.searchParams.get("workspace"),
  298. }),
  299. {
  300. status: 201,
  301. statusText: "Created",
  302. headers: {
  303. "content-length": "999",
  304. "content-type": "application/json",
  305. "x-remote": "yes",
  306. },
  307. },
  308. )
  309. })
  310. const project = yield* Project.use.fromDirectory(dir)
  311. registerAdapter(
  312. project.project.id,
  313. "remote-target",
  314. remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
  315. "x-target-auth": "secret",
  316. }),
  317. )
  318. const created = yield* request(WorkspacePaths.list, dir, {
  319. method: "POST",
  320. headers: { "content-type": "application/json" },
  321. body: JSON.stringify({ type: "remote-target", branch: null }),
  322. })
  323. const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
  324. const url = new URL("http://localhost/config")
  325. url.searchParams.set("workspace", workspace.id)
  326. url.searchParams.set("keep", "yes")
  327. try {
  328. const response = yield* request(url.toString(), dir, {
  329. method: "PATCH",
  330. headers: {
  331. "accept-encoding": "br",
  332. "content-type": "application/json",
  333. "x-opencode-workspace": "internal",
  334. },
  335. body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
  336. })
  337. const responseBody = yield* Effect.promise(() => response.text())
  338. expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
  339. expect(response.headers.get("content-length")).toBeNull()
  340. expect(response.headers.get("x-remote")).toBe("yes")
  341. expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
  342. const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
  343. expect(forwarded).toEqual([
  344. {
  345. url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
  346. method: "PATCH",
  347. headers: expect.objectContaining({
  348. "content-type": "application/json",
  349. "x-target-auth": "secret",
  350. }),
  351. body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
  352. },
  353. ])
  354. expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
  355. expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
  356. } finally {
  357. void remote.stop(true)
  358. yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  359. }
  360. }),
  361. )
  362. it.live("proxies remote workspace requests selected from session ownership", () =>
  363. Effect.gen(function* () {
  364. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  365. const dir = yield* tmpdirScoped({ git: true })
  366. const proxied: ProxiedRequest[] = []
  367. const remote = listenRemoteHttp((request) => {
  368. proxied.push(request)
  369. const url = new URL(request.url)
  370. if (url.pathname === "/base/global/event") return eventStreamResponse()
  371. if (url.pathname === "/base/sync/history") return Response.json([])
  372. return Response.json({ proxied: true, path: new URL(request.url).pathname })
  373. })
  374. const project = yield* Project.use.fromDirectory(dir)
  375. registerAdapter(
  376. project.project.id,
  377. "remote-session-target",
  378. remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
  379. )
  380. const created = yield* request(WorkspacePaths.list, dir, {
  381. method: "POST",
  382. headers: { "content-type": "application/json" },
  383. body: JSON.stringify({ type: "remote-session-target", branch: null }),
  384. })
  385. const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
  386. const session = yield* Session.Service.use((svc) => svc.create()).pipe(
  387. Effect.provideService(WorkspaceRef, workspace.id),
  388. provideInstance(dir),
  389. )
  390. try {
  391. const response = yield* request(`http://localhost/session/${session.id}/message`, dir, {
  392. method: "POST",
  393. headers: { "content-type": "application/json" },
  394. body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
  395. })
  396. const responseBody = yield* Effect.promise(() => response.text())
  397. expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
  398. expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
  399. expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
  400. expect.objectContaining({
  401. url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
  402. method: "POST",
  403. }),
  404. ])
  405. } finally {
  406. void remote.stop(true)
  407. yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  408. }
  409. }),
  410. )
  411. })