httpapi-ui.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import { createHash } from "node:crypto"
  2. import { describe, expect } from "bun:test"
  3. import { Flag } from "@opencode-ai/core/flag/flag"
  4. import * as Log from "@opencode-ai/core/util/log"
  5. import { ConfigProvider, Effect, Layer } from "effect"
  6. import {
  7. HttpClient,
  8. HttpClientRequest,
  9. HttpClientResponse,
  10. HttpRouter,
  11. HttpServer,
  12. HttpServerRequest,
  13. HttpServerResponse,
  14. } from "effect/unstable/http"
  15. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  16. import { RuntimeFlags } from "../../src/effect/runtime-flags"
  17. import { ServerAuth } from "../../src/server/auth"
  18. import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
  19. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  20. import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
  21. import { testEffect } from "../lib/effect"
  22. void Log.init({ print: false })
  23. const testStateLayer = Layer.effectDiscard(
  24. Effect.gen(function* () {
  25. const original = {
  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. yield* Effect.addFinalizer(() =>
  32. Effect.sync(() => {
  33. Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
  34. Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
  35. restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
  36. restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
  37. }),
  38. )
  39. }),
  40. )
  41. const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, RuntimeFlags.layer()))
  42. function restoreEnv(key: string, value: string | undefined) {
  43. if (value === undefined) {
  44. delete process.env[key]
  45. return
  46. }
  47. process.env[key] = value
  48. }
  49. function app(input?: { password?: string; username?: string }) {
  50. const handler = HttpRouter.toWebHandler(
  51. HttpApiApp.routes.pipe(
  52. Layer.provide(
  53. ConfigProvider.layer(
  54. ConfigProvider.fromUnknown({
  55. OPENCODE_SERVER_PASSWORD: input?.password,
  56. OPENCODE_SERVER_USERNAME: input?.username,
  57. }),
  58. ),
  59. ),
  60. ),
  61. { disableLogger: true },
  62. ).handler
  63. return {
  64. request(input: string | URL | Request, init?: RequestInit) {
  65. return Effect.promise(() =>
  66. Promise.resolve(
  67. handler(
  68. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  69. HttpApiApp.context,
  70. ),
  71. ),
  72. )
  73. },
  74. }
  75. }
  76. function uiApp(input?: {
  77. password?: string
  78. username?: string
  79. client?: Layer.Layer<HttpClient.HttpClient>
  80. disableEmbeddedWebUi?: boolean
  81. }) {
  82. const handler = HttpRouter.toWebHandler(
  83. HttpRouter.use((router) =>
  84. Effect.gen(function* () {
  85. const fs = yield* AppFileSystem.Service
  86. const client = yield* HttpClient.HttpClient
  87. const flags = yield* RuntimeFlags.Service
  88. yield* router.add("*", "/*", (request) =>
  89. serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
  90. )
  91. }),
  92. ).pipe(
  93. Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
  94. Layer.provide([
  95. AppFileSystem.defaultLayer,
  96. input?.client ?? httpClient(new Response("ui")),
  97. RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
  98. HttpServer.layerServices,
  99. ConfigProvider.layer(
  100. ConfigProvider.fromUnknown({
  101. OPENCODE_SERVER_PASSWORD: input?.password,
  102. OPENCODE_SERVER_USERNAME: input?.username,
  103. }),
  104. ),
  105. ]),
  106. ),
  107. { disableLogger: true },
  108. ).handler
  109. return {
  110. request(input: string | URL | Request, init?: RequestInit) {
  111. return Effect.promise(() =>
  112. Promise.resolve(
  113. handler(
  114. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  115. HttpApiApp.context,
  116. ),
  117. ),
  118. )
  119. },
  120. }
  121. }
  122. function routeOrderingApp() {
  123. let proxiedUrl: string | undefined
  124. const handler = HttpRouter.toWebHandler(
  125. HttpRouter.use((router) =>
  126. Effect.gen(function* () {
  127. const fs = yield* AppFileSystem.Service
  128. const client = yield* HttpClient.HttpClient
  129. const flags = yield* RuntimeFlags.Service
  130. yield* router.add("GET", "/session/:sessionID", () =>
  131. Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })),
  132. )
  133. yield* router.add("*", "/*", (request) =>
  134. serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
  135. )
  136. }),
  137. ).pipe(
  138. Layer.provide([
  139. AppFileSystem.defaultLayer,
  140. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  141. httpClient(new Response("ui"), (request) => {
  142. proxiedUrl = request.url
  143. }),
  144. HttpServer.layerServices,
  145. ]),
  146. ),
  147. { disableLogger: true },
  148. ).handler
  149. return {
  150. proxiedUrl: () => proxiedUrl,
  151. request(input: string | URL | Request, init?: RequestInit) {
  152. return Effect.promise(() =>
  153. Promise.resolve(
  154. handler(
  155. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  156. HttpApiApp.context,
  157. ),
  158. ),
  159. )
  160. },
  161. }
  162. }
  163. function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
  164. return Layer.succeed(
  165. HttpClient.HttpClient,
  166. HttpClient.make((request) => {
  167. onRequest?.(request)
  168. return Effect.succeed(HttpClientResponse.fromWeb(request, response))
  169. }),
  170. )
  171. }
  172. function responseText(response: Response) {
  173. return Effect.promise(() => response.text())
  174. }
  175. describe("HttpApi UI fallback", () => {
  176. it.live("serves the web UI through the HTTP API app", () =>
  177. Effect.gen(function* () {
  178. let proxiedUrl: string | undefined
  179. const response = yield* uiApp({
  180. disableEmbeddedWebUi: true,
  181. client: httpClient(
  182. new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
  183. (request) => {
  184. proxiedUrl = request.url
  185. },
  186. ),
  187. }).request("/")
  188. expect(response.status).toBe(200)
  189. expect(response.headers.get("content-type")).toContain("text/html")
  190. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  191. expect(proxiedUrl).toBe("https://app.opencode.ai/")
  192. }),
  193. )
  194. it.live("strips upstream transfer encoding headers from proxied assets", () =>
  195. Effect.gen(function* () {
  196. let proxiedUrl: string | undefined
  197. const response = yield* Effect.gen(function* () {
  198. const fs = yield* AppFileSystem.Service
  199. const client = yield* HttpClient.HttpClient
  200. const flags = yield* RuntimeFlags.Service
  201. return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
  202. fs,
  203. client,
  204. disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
  205. })
  206. }).pipe(
  207. Effect.provide(
  208. Layer.mergeAll(
  209. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  210. Layer.succeed(
  211. HttpClient.HttpClient,
  212. HttpClient.make((request) => {
  213. proxiedUrl = request.url
  214. return Effect.succeed(
  215. HttpClientResponse.fromWeb(
  216. request,
  217. new Response("console.log('ok')", {
  218. headers: {
  219. "content-encoding": "br",
  220. "content-length": "999",
  221. "content-type": "text/javascript",
  222. },
  223. }),
  224. ),
  225. )
  226. }),
  227. ),
  228. ),
  229. ),
  230. Effect.map(HttpServerResponse.toWeb),
  231. )
  232. expect(response.status).toBe(200)
  233. expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
  234. expect(response.headers.get("content-encoding")).toBeNull()
  235. expect(response.headers.get("content-length")).not.toBe("999")
  236. expect(response.headers.get("content-type")).toContain("text/javascript")
  237. expect(yield* responseText(response)).toBe("console.log('ok')")
  238. }),
  239. )
  240. // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
  241. // forwarded through the proxy while the proxy itself re-frames the body,
  242. // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
  243. it.live("strips upstream transfer-encoding header from proxied assets", () =>
  244. Effect.gen(function* () {
  245. const response = yield* Effect.gen(function* () {
  246. const fs = yield* AppFileSystem.Service
  247. const client = yield* HttpClient.HttpClient
  248. const flags = yield* RuntimeFlags.Service
  249. return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
  250. fs,
  251. client,
  252. disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
  253. })
  254. }).pipe(
  255. Effect.provide(
  256. Layer.mergeAll(
  257. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  258. Layer.succeed(
  259. HttpClient.HttpClient,
  260. HttpClient.make((request) =>
  261. Effect.succeed(
  262. HttpClientResponse.fromWeb(
  263. request,
  264. new Response("<html>opencode</html>", {
  265. headers: {
  266. "transfer-encoding": "chunked",
  267. "content-type": "text/html",
  268. },
  269. }),
  270. ),
  271. ),
  272. ),
  273. ),
  274. ),
  275. ),
  276. Effect.map(HttpServerResponse.toWeb),
  277. )
  278. expect(response.status).toBe(200)
  279. expect(response.headers.get("transfer-encoding")).toBeNull()
  280. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  281. }),
  282. )
  283. it.live("serves embedded UI assets when Bun can read them but access reports missing", () =>
  284. Effect.gen(function* () {
  285. let readPath: string | undefined
  286. const fs = yield* AppFileSystem.Service
  287. const response = yield* serveEmbeddedUIEffect(
  288. "/assets/app.js",
  289. {
  290. ...fs,
  291. existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
  292. readFile: (path) => {
  293. readPath = path
  294. return path === "/$bunfs/root/assets/app.js"
  295. ? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
  296. : Effect.die(`unexpected embedded UI path: ${path}`)
  297. },
  298. },
  299. { "assets/app.js": "/$bunfs/root/assets/app.js" },
  300. ).pipe(Effect.map(HttpServerResponse.toWeb))
  301. expect(response.status).toBe(200)
  302. expect(readPath).toBe("/$bunfs/root/assets/app.js")
  303. expect(response.headers.get("content-type")).toContain("text/javascript")
  304. expect(yield* responseText(response)).toBe("console.log('embedded')")
  305. }),
  306. )
  307. it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
  308. Effect.gen(function* () {
  309. const script = 'document.documentElement.dataset.theme = "dark"'
  310. const fs = yield* AppFileSystem.Service
  311. const response = yield* serveEmbeddedUIEffect(
  312. "/",
  313. {
  314. ...fs,
  315. readFile: (path) => {
  316. return path === "/$bunfs/root/index.html"
  317. ? Effect.succeed(
  318. new TextEncoder().encode(
  319. `<html><head><script id="oc-theme-preload-script">${script}</script></head></html>`,
  320. ),
  321. )
  322. : Effect.die(`unexpected embedded UI path: ${path}`)
  323. },
  324. },
  325. { "index.html": "/$bunfs/root/index.html" },
  326. ).pipe(Effect.map(HttpServerResponse.toWeb))
  327. const csp = response.headers.get("content-security-policy") ?? ""
  328. expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
  329. expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
  330. expect(csp).toContain("connect-src * data:")
  331. }),
  332. )
  333. it.live("keeps matched API routes ahead of the UI fallback", () =>
  334. Effect.gen(function* () {
  335. const server = routeOrderingApp()
  336. const response = yield* server.request("/session/ses_nope")
  337. expect(response.status).toBe(404)
  338. expect(server.proxiedUrl()).toBeUndefined()
  339. }),
  340. )
  341. it.live("requires server password for the web UI", () =>
  342. Effect.gen(function* () {
  343. const response = yield* uiApp({
  344. password: "secret",
  345. username: "opencode",
  346. disableEmbeddedWebUi: true,
  347. }).request("/")
  348. expect(response.status).toBe(401)
  349. expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
  350. }),
  351. )
  352. it.live("accepts auth token for the web UI", () =>
  353. Effect.gen(function* () {
  354. const response = yield* uiApp({
  355. password: "secret",
  356. username: "opencode",
  357. disableEmbeddedWebUi: true,
  358. client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
  359. }).request(`/?auth_token=${btoa("opencode:secret")}`)
  360. expect(response.status).toBe(200)
  361. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  362. }),
  363. )
  364. it.live("accepts basic auth for the web UI", () =>
  365. Effect.gen(function* () {
  366. const response = yield* uiApp({
  367. password: "secret",
  368. username: "opencode",
  369. disableEmbeddedWebUi: true,
  370. }).request("/", {
  371. headers: { authorization: `Basic ${btoa("opencode:secret")}` },
  372. })
  373. expect(response.status).toBe(200)
  374. }),
  375. )
  376. // Regression for #25698 (Ope): the browser fetches the PWA manifest and
  377. // its icons via flows that don't carry app-managed credentials (the
  378. // `<link rel="manifest">` request is not under page-auth control), so the
  379. // server returning 401 breaks PWA install. These specific public assets
  380. // should bypass auth.
  381. it.live("serves the PWA manifest without auth even when a server password is set", () =>
  382. Effect.gen(function* () {
  383. for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
  384. const response = yield* uiApp({
  385. password: "secret",
  386. username: "opencode",
  387. disableEmbeddedWebUi: true,
  388. client: httpClient(new Response("ok")),
  389. }).request(path)
  390. expect(response.status).not.toBe(401)
  391. }
  392. }),
  393. )
  394. it.live("allows web UI preflight without auth", () =>
  395. Effect.gen(function* () {
  396. const response = yield* app({ password: "secret", username: "opencode" }).request("/", {
  397. method: "OPTIONS",
  398. headers: {
  399. origin: "http://localhost:3000",
  400. "access-control-request-method": "GET",
  401. },
  402. })
  403. expect(response.status).toBe(204)
  404. expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
  405. }),
  406. )
  407. })