httpapi-ui.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { Flag } from "@opencode-ai/core/flag/flag"
  3. import * as Log from "@opencode-ai/core/util/log"
  4. import { ConfigProvider, Effect, Layer } from "effect"
  5. import {
  6. HttpClient,
  7. HttpClientRequest,
  8. HttpClientResponse,
  9. HttpRouter,
  10. HttpServer,
  11. HttpServerRequest,
  12. HttpServerResponse,
  13. } from "effect/unstable/http"
  14. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  15. import {
  16. ServerAuthConfig,
  17. authorizationRouterMiddleware,
  18. } from "../../src/server/routes/instance/httpapi/middleware/authorization"
  19. import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
  20. import { serveUIEffect } from "../../src/server/routes/ui"
  21. import { Server } from "../../src/server/server"
  22. void Log.init({ print: false })
  23. const original = {
  24. OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
  25. OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI,
  26. OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
  27. OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
  28. envPassword: process.env.OPENCODE_SERVER_PASSWORD,
  29. envUsername: process.env.OPENCODE_SERVER_USERNAME,
  30. }
  31. afterEach(() => {
  32. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
  33. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI
  34. Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
  35. Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
  36. restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
  37. restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
  38. })
  39. function restoreEnv(key: string, value: string | undefined) {
  40. if (value === undefined) {
  41. delete process.env[key]
  42. return
  43. }
  44. process.env[key] = value
  45. }
  46. function app(input?: { password?: string; username?: string }) {
  47. const handler = HttpRouter.toWebHandler(
  48. ExperimentalHttpApiServer.routes.pipe(
  49. Layer.provide(
  50. ConfigProvider.layer(
  51. ConfigProvider.fromUnknown({
  52. OPENCODE_SERVER_PASSWORD: input?.password,
  53. OPENCODE_SERVER_USERNAME: input?.username,
  54. }),
  55. ),
  56. ),
  57. ),
  58. { disableLogger: true },
  59. ).handler
  60. return {
  61. request(input: string | URL | Request, init?: RequestInit) {
  62. return handler(
  63. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  64. ExperimentalHttpApiServer.context,
  65. )
  66. },
  67. }
  68. }
  69. function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
  70. const handler = HttpRouter.toWebHandler(
  71. HttpRouter.use((router) =>
  72. Effect.gen(function* () {
  73. const fs = yield* AppFileSystem.Service
  74. const client = yield* HttpClient.HttpClient
  75. yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
  76. }),
  77. ).pipe(
  78. Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
  79. Layer.provide([
  80. AppFileSystem.defaultLayer,
  81. input?.client ?? httpClient(new Response("ui")),
  82. HttpServer.layerServices,
  83. ConfigProvider.layer(
  84. ConfigProvider.fromUnknown({
  85. OPENCODE_SERVER_PASSWORD: input?.password,
  86. OPENCODE_SERVER_USERNAME: input?.username,
  87. }),
  88. ),
  89. ]),
  90. ),
  91. { disableLogger: true },
  92. ).handler
  93. return {
  94. request(input: string | URL | Request, init?: RequestInit) {
  95. return handler(
  96. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  97. ExperimentalHttpApiServer.context,
  98. )
  99. },
  100. }
  101. }
  102. function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
  103. return Layer.succeed(
  104. HttpClient.HttpClient,
  105. HttpClient.make((request) => {
  106. onRequest?.(request)
  107. return Effect.succeed(HttpClientResponse.fromWeb(request, response))
  108. }),
  109. )
  110. }
  111. describe("HttpApi UI fallback", () => {
  112. test("serves the web UI through the experimental backend", async () => {
  113. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  114. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
  115. let proxiedUrl: string | undefined
  116. const response = await uiApp({
  117. client: httpClient(
  118. new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
  119. (request) => {
  120. proxiedUrl = request.url
  121. },
  122. ),
  123. }).request("/")
  124. expect(response.status).toBe(200)
  125. expect(response.headers.get("content-type")).toContain("text/html")
  126. expect(await response.text()).toBe("<html>opencode</html>")
  127. expect(proxiedUrl).toBe("https://app.opencode.ai/")
  128. })
  129. test("strips upstream transfer encoding headers from proxied assets", async () => {
  130. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  131. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
  132. let proxiedUrl: string | undefined
  133. const response = await Effect.runPromise(
  134. Effect.gen(function* () {
  135. const fs = yield* AppFileSystem.Service
  136. const client = yield* HttpClient.HttpClient
  137. return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
  138. fs,
  139. client,
  140. })
  141. }).pipe(
  142. Effect.provide(
  143. Layer.mergeAll(
  144. AppFileSystem.defaultLayer,
  145. Layer.succeed(
  146. HttpClient.HttpClient,
  147. HttpClient.make((request) => {
  148. proxiedUrl = request.url
  149. return Effect.succeed(
  150. HttpClientResponse.fromWeb(
  151. request,
  152. new Response("console.log('ok')", {
  153. headers: {
  154. "content-encoding": "br",
  155. "content-length": "999",
  156. "content-type": "text/javascript",
  157. },
  158. }),
  159. ),
  160. )
  161. }),
  162. ),
  163. ),
  164. ),
  165. Effect.map(HttpServerResponse.toWeb),
  166. ),
  167. )
  168. expect(response.status).toBe(200)
  169. expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
  170. expect(response.headers.get("content-encoding")).toBeNull()
  171. expect(response.headers.get("content-length")).not.toBe("999")
  172. expect(response.headers.get("content-type")).toContain("text/javascript")
  173. expect(await response.text()).toBe("console.log('ok')")
  174. })
  175. test("keeps matched API routes ahead of the UI fallback", async () => {
  176. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  177. const response = await Server.Default().app.request("/session/nope")
  178. expect(response.status).toBe(404)
  179. })
  180. test("requires server password for the web UI", async () => {
  181. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  182. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
  183. const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
  184. expect(response.status).toBe(401)
  185. })
  186. test("accepts auth token for the web UI", async () => {
  187. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  188. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
  189. const response = await uiApp({
  190. password: "secret",
  191. username: "opencode",
  192. client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
  193. }).request(`/?auth_token=${btoa("opencode:secret")}`)
  194. expect(response.status).toBe(200)
  195. expect(await response.text()).toBe("<html>opencode</html>")
  196. })
  197. test("accepts basic auth for the web UI", async () => {
  198. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  199. Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
  200. const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
  201. headers: { authorization: `Basic ${btoa("opencode:secret")}` },
  202. })
  203. expect(response.status).toBe(200)
  204. })
  205. test("allows web UI preflight without auth", async () => {
  206. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  207. const response = await app({ password: "secret", username: "opencode" }).request("/", {
  208. method: "OPTIONS",
  209. headers: {
  210. origin: "http://localhost:3000",
  211. "access-control-request-method": "GET",
  212. },
  213. })
  214. expect(response.status).toBe(204)
  215. expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
  216. })
  217. })