diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f62636bca8..6e90126964 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,7 +1,8 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -38,7 +39,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" -import { UIRoutes } from "@/server/routes/ui" +import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" @@ -120,18 +121,10 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -const uiRoutes = lazy(() => UIRoutes()) const uiRoute = HttpRouter.add("*", "/*", (request) => - Effect.promise(async () => - uiRoutes().fetch( - request.source instanceof Request - ? request.source - : new Request(new URL(request.originalUrl, "http://localhost"), { - method: request.method, - headers: request.headers, - }), - ), - ).pipe(Effect.map(HttpServerResponse.fromWeb)), + serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)), +).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 5e47e6bf71..322f63cddb 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,9 +1,13 @@ import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" import { proxy } from "hono/proxy" import { getMimeType } from "hono/utils/mime" import { createHash } from "node:crypto" import fs from "node:fs/promises" +import { ProxyUtil } from "../proxy-util" const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI ? Promise.resolve(null) @@ -12,44 +16,119 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI const DEFAULT_CSP = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +const UI_UPSTREAM = new URL("https://app.opencode.ai") const csp = (hash = "") => `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` -export const UIRoutes = (): Hono => - new Hono().all("/*", async (c) => { - const embeddedWebUI = await embeddedUIPromise - const path = c.req.path +function themePreloadHash(body: string) { + return body.match( + /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, + ) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream( + request.stream, + request.headers["content-type"], + len === undefined ? undefined : Number(len), + ) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + return result +} + +function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +function embeddedUI() { + if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +export async function serveUI(request: Request) { + const embeddedWebUI = await embeddedUI() + const path = new URL(request.url).pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) + + if (await fs.exists(match)) { + const mime = getMimeType(match) ?? "text/plain" + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return new Response(new Uint8Array(await fs.readFile(match)), { headers }) + } + + return Response.json({ error: "Not Found" }, { status: 404 }) + } + + const response = await proxy(upstreamURL(path), { + raw: request, + headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), + }) + const match = response.headers.get("content-type")?.includes("text/html") + ? themePreloadHash(await response.clone().text()) + : undefined + const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" + response.headers.set("Content-Security-Policy", csp(hash)) + return response +} + +export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname if (embeddedWebUI) { const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return c.json({ error: "Not Found" }, 404) + if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - if (await fs.exists(match)) { + const fs = yield* AppFileSystem.Service + if (yield* fs.existsSafe(match)) { const mime = getMimeType(match) ?? "text/plain" - c.header("Content-Type", mime) - if (mime.startsWith("text/html")) { - c.header("Content-Security-Policy", DEFAULT_CSP) - } - return c.body(new Uint8Array(await fs.readFile(match))) - } else { - return c.json({ error: "Not Found" }, 404) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(yield* fs.readFile(match), { headers }) } - } else { - const response = await proxy(`https://app.opencode.ai${path}`, { - raw: c.req.raw, - headers: { - ...Object.fromEntries(c.req.raw.headers.entries()), - host: "app.opencode.ai", - }, - }) - const match = response.headers.get("content-type")?.includes("text/html") - ? (await response.clone().text()).match( - /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, - ) - : undefined - const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" - response.headers.set("Content-Security-Policy", csp(hash)) - return response + + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } + + const response = yield* HttpClient.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set( + "Content-Security-Policy", + csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""), + ) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) }) +} + +export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 9ca8b49f1b..9dd2ea77c0 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,30 +1,130 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" +import { ConfigProvider, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { ServerAuthConfig, authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { serveUIEffect } from "../../src/server/routes/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - fetch: globalThis.fetch, + OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, } afterEach(() => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - globalThis.fetch = original.fetch + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) + restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) }) +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} + +function app(input?: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function uiApp(input?: { + password?: string + username?: string + client?: Layer.Layer +}) { + const handler = HttpRouter.toWebHandler( + HttpRouter.add("*", "/*", (request) => + serveUIEffect(request).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide(input?.client ?? httpClient(new Response("ui"))), + ), + ).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(HttpServer.layerServices), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + onRequest?.(request) + return Effect.succeed(HttpClientResponse.fromWeb(request, response)) + }), + ) +} + describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined - globalThis.fetch = ((input: RequestInfo | URL) => { - proxiedUrl = String(input instanceof Request ? input.url : input) - return Promise.resolve(new Response("opencode", { headers: { "content-type": "text/html" } })) - }) as typeof fetch - const response = await Server.Default().app.request("/") + const response = await uiApp({ + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } }), (request) => { + proxiedUrl = request.url + }), + }).request("/") expect(response.status).toBe(200) expect(response.headers.get("content-type")).toContain("text/html") @@ -32,14 +132,102 @@ describe("HttpApi UI fallback", () => { expect(proxiedUrl).toBe("https://app.opencode.ai/") }) + test("strips upstream transfer encoding headers from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined + + const response = await Effect.runPromise( + serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js"))).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") + expect(response.headers.get("content-encoding")).toBeNull() + expect(response.headers.get("content-length")).not.toBe("999") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('ok')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - globalThis.fetch = (() => { - throw new Error("UI fallback should not handle matched API routes") - }) as unknown as typeof fetch const response = await Server.Default().app.request("/session/nope") expect(response.status).toBe(404) }) + + test("requires server password for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/") + + expect(response.status).toBe(401) + }) + + test("accepts auth token for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), + }).request( + `/?auth_token=${btoa("opencode:secret")}`, + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("opencode") + }) + + test("accepts basic auth for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }) + + expect(response.status).toBe(200) + }) + + test("allows web UI preflight without auth", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + + const response = await app({ password: "secret", username: "opencode" }).request("/", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3000", + "access-control-request-method": "GET", + }, + }) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + }) })