httpapi-workspace.test.ts 18 KB

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