httpapi-v2-pty.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "effect"
  3. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  4. import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
  5. import * as Socket from "effect/unstable/socket/Socket"
  6. import path from "path"
  7. import { pathToFileURL } from "url"
  8. import { mkdir } from "fs/promises"
  9. import { Location } from "@kirincode-ai/core/location"
  10. import { Pty } from "@kirincode-ai/core/pty"
  11. import { PtyTicket } from "@kirincode-ai/core/pty/ticket"
  12. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  13. import { resetDatabase } from "../fixture/db"
  14. import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
  15. import { testEffect } from "../lib/effect"
  16. const context = Context.empty() as Context.Context<unknown>
  17. const testPty = process.platform === "win32" ? test.skip : test
  18. function request(route: string, directory: string, init: RequestInit = {}) {
  19. const headers = new Headers(init.headers)
  20. headers.set("x-opencode-directory", directory)
  21. return HttpApiApp.webHandler().handler(
  22. new Request(`http://localhost${route}`, {
  23. ...init,
  24. headers,
  25. }),
  26. context,
  27. )
  28. }
  29. const testStateLayer = Layer.effectDiscard(
  30. Effect.gen(function* () {
  31. yield* Effect.promise(() => resetDatabase())
  32. yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
  33. }),
  34. )
  35. const servedRoutes: Layer.Layer<never, EffectConfig.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
  36. HttpApiApp.routes,
  37. { disableListenLog: true, disableLogger: true },
  38. )
  39. const effectIt = testEffect(
  40. Layer.mergeAll(
  41. testStateLayer,
  42. Socket.layerWebSocketConstructorGlobal,
  43. servedRoutes.pipe(
  44. Layer.provide(Socket.layerWebSocketConstructorGlobal),
  45. Layer.provideMerge(NodeHttpServer.layerTest),
  46. Layer.provideMerge(NodeServices.layer),
  47. ),
  48. ),
  49. )
  50. const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
  51. const serverUrl = () => HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
  52. afterEach(async () => {
  53. await disposeAllInstances()
  54. await resetDatabase()
  55. })
  56. describe("v2 pty HttpApi", () => {
  57. testPty("serves location-wrapped PTY routes and retains exited sessions", async () => {
  58. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  59. const empty = await request("/api/pty", tmp.path)
  60. expect(empty.status).toBe(200)
  61. expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Pty.Info)))(await empty.json()).data).toEqual([])
  62. const created = await request("/api/pty", tmp.path, {
  63. method: "POST",
  64. headers: { "content-type": "application/json" },
  65. body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 4"], title: "v2" }),
  66. })
  67. expect(created.status).toBe(200)
  68. const body = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json())
  69. expect(String(body.location.directory)).toBe(tmp.path)
  70. expect(body.data.title).toBe("v2")
  71. // The canonical surface keeps exited sessions observable with their exit code.
  72. const deadline = Date.now() + 5_000
  73. let info: { status: string; exitCode?: number } | undefined
  74. while (Date.now() < deadline) {
  75. const found = await request(`/api/pty/${body.data.id}`, tmp.path)
  76. expect(found.status).toBe(200)
  77. info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await found.json()).data
  78. if (info.status === "exited") break
  79. await new Promise((resolve) => setTimeout(resolve, 50))
  80. }
  81. expect(info).toMatchObject({ status: "exited", exitCode: 4 })
  82. const removed = await request(`/api/pty/${body.data.id}`, tmp.path, { method: "DELETE" })
  83. expect(removed.status).toBe(204)
  84. const missing = await request(`/api/pty/${body.data.id}`, tmp.path)
  85. expect(missing.status).toBe(404)
  86. expect(await missing.json()).toMatchObject({ _tag: "PtyNotFoundError", ptyID: body.data.id })
  87. })
  88. testPty("rejects connect tokens without the CSRF header and connects with a valid ticket", async () => {
  89. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  90. const created = await request("/api/pty", tmp.path, {
  91. method: "POST",
  92. headers: { "content-type": "application/json" },
  93. body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
  94. })
  95. expect(created.status).toBe(200)
  96. const info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()).data
  97. try {
  98. const forbidden = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { method: "POST" })
  99. expect(forbidden.status).toBe(403)
  100. expect(await forbidden.json()).toMatchObject({ _tag: "ForbiddenError" })
  101. const token = await request(`/api/pty/${info.id}/connect-token`, tmp.path, {
  102. method: "POST",
  103. headers: { "x-opencode-ticket": "1" },
  104. })
  105. expect(token.status).toBe(200)
  106. const ticket = Schema.decodeUnknownSync(Location.response(PtyTicket.ConnectToken))(await token.json()).data.ticket
  107. expect(ticket).toBeTruthy()
  108. const invalid = await request(`/api/pty/${info.id}/connect?ticket=not-a-ticket`, tmp.path)
  109. expect(invalid.status).toBe(403)
  110. } finally {
  111. await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" })
  112. }
  113. })
  114. ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
  115. "serves PTY websocket output and input through the canonical route",
  116. () =>
  117. Effect.gen(function* () {
  118. const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
  119. const created = yield* HttpClientRequest.post("/api/pty").pipe(
  120. directoryHeader(dir),
  121. HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }),
  122. Effect.flatMap(HttpClient.execute),
  123. )
  124. expect(created.status).toBe(200)
  125. const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
  126. const info = body.data
  127. const socket = yield* Socket.makeWebSocket(
  128. `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`,
  129. { closeCodeIsError: () => false },
  130. )
  131. const messages = yield* Queue.unbounded<string>()
  132. yield* socket
  133. .runRaw((message) =>
  134. Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
  135. )
  136. .pipe(Effect.catch(() => Effect.void))
  137. .pipe(Effect.forkScoped)
  138. const write = yield* socket.writer
  139. const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
  140. Effect.gen(function* () {
  141. const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
  142. if (next.includes(expected)) return next
  143. return yield* takeUntil(expected, next)
  144. })
  145. yield* write("ping-v2\n")
  146. expect(yield* takeUntil("ping-v2")).toContain("ping-v2")
  147. yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
  148. const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
  149. directoryHeader(dir),
  150. HttpClient.execute,
  151. )
  152. expect(removed.status).toBe(204)
  153. }),
  154. )
  155. ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
  156. "applies plugin shell environment before forced PTY values",
  157. () =>
  158. Effect.gen(function* () {
  159. const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
  160. const plugin = path.join(dir, "plugin.ts")
  161. const cwd = path.join(dir, "child")
  162. yield* Effect.promise(() => mkdir(cwd))
  163. yield* Effect.promise(() =>
  164. Bun.write(
  165. plugin,
  166. [
  167. "export default async () => ({",
  168. ' "shell.env": (input, output) => {',
  169. ' output.env.SHARED = "plugin"',
  170. ' output.env.PLUGIN = "plugin"',
  171. ' output.env.TERM = "plugin"',
  172. " output.env.HOOK_CWD = input.cwd",
  173. " },",
  174. "})",
  175. "",
  176. ].join("\n"),
  177. ),
  178. )
  179. yield* Effect.promise(() =>
  180. Bun.write(
  181. path.join(dir, "kirincode.json"),
  182. JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }),
  183. ),
  184. )
  185. const created = yield* HttpClientRequest.post("/api/pty").pipe(
  186. directoryHeader(dir),
  187. HttpClientRequest.bodyJson({
  188. command: "/bin/sh",
  189. args: ["-c", 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5'],
  190. cwd,
  191. env: { CALLER: "caller", SHARED: "caller", TERM: "caller" },
  192. }),
  193. Effect.flatMap(HttpClient.execute),
  194. )
  195. expect(created.status).toBe(200)
  196. const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data
  197. const socket = yield* Socket.makeWebSocket(
  198. `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`,
  199. { closeCodeIsError: () => false },
  200. )
  201. const messages = yield* Queue.unbounded<string>()
  202. yield* socket
  203. .runRaw((message) =>
  204. Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
  205. )
  206. .pipe(
  207. Effect.catch(() => Effect.void),
  208. Effect.forkScoped,
  209. )
  210. const write = yield* socket.writer
  211. const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
  212. Effect.gen(function* () {
  213. const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
  214. if (next.includes(expected)) return next
  215. return yield* takeUntil(expected, next)
  216. })
  217. expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain(
  218. `caller|plugin|plugin|xterm-256color|${cwd}`,
  219. )
  220. yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
  221. yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
  222. }),
  223. )
  224. })