diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index f7bfa75e45..ffe5778295 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -39,7 +39,7 @@ export default Runtime.handler(Commands, (input) => ) preflight.loading() const endpoint = yield* Ref.make(server.endpoint) - const web = yield* Effect.cached(WebUi.start(endpoint)) + const web = Ref.get(endpoint).pipe(Effect.map(WebUi.url)) const config = yield* Config.Service const npm = yield* Npm.Service const fileSystem = yield* FileSystem.FileSystem diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 45dc7d9307..0bf225f229 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -1,14 +1,14 @@ export * as ServerProcess from "./server-process" import { NodeServices } from "@effect/platform-node" -import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service" +import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version" import { AppProcess } from "@opencode-ai/util/process" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" -import { Effect, FileSystem, Option, Redacted, Ref, Schedule, Schema } from "effect" +import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" @@ -70,6 +70,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const instanceID = randomUUID() + const transform = yield* WebUi.handler() const server = yield* start( { app: { @@ -77,8 +78,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL, }, - hostname: foreground ? "127.0.0.1" : hostname, - port: foreground ? 0 : port, + hostname, + port, password, simulation: truthy(process.env.OPENCODE_SIMULATE), database: { @@ -124,6 +125,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { return yield* register(address, password, instanceID, serviceOptions.file, shutdown) }), }, + transform, ).pipe( Effect.catch((error) => { if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error) @@ -143,23 +145,12 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { }), ) if (server === undefined) return - const url = - foreground - ? yield* WebUi.serve( - yield* Ref.make({ - url: HttpServer.formatAddress(server.address), - auth: { type: "basic", username: "opencode", password }, - }), - { hostname, port, password }, - ) - : HttpServer.formatAddress(server.address) + const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (foreground && !environmentPassword) console.log(`server password ${password}`) if (options.mode === "web") { - const target = new URL(url) + const target = new URL(WebUi.url({ url, auth: { type: "basic", username: "opencode", password } })) if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost" - target.username = "opencode" - target.password = password yield* Effect.promise(() => open(target.toString()).catch(() => undefined)) } const updater = yield* Updater.Service diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 1daeb9f27c..4ac43d9fc0 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -1,25 +1,20 @@ -import { NodeHttpServer, NodeSocket } from "@effect/platform-node" -import { Service, type Endpoint } from "@opencode-ai/client/effect/service" -import { ServerInfo } from "@opencode-ai/server/server-info" +import type { Endpoint } from "@opencode-ai/client/effect/service" import { FSUtil } from "@opencode-ai/util/fs-util" -import { Context, Effect, Exit, Ref, Scope, Stream } from "effect" +import { Effect, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest, - HttpServer, + HttpServerError, HttpServerRequest, HttpServerResponse, } from "effect/unstable/http" -import { Socket } from "effect/unstable/socket" -import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { createHash } from "node:crypto" import { readFile } from "node:fs/promises" -import { createServer } from "node:http" import { load } from "../app-assets" const UI_UPSTREAM = new URL("https://app.opencode.ai") -const COOKIE = "opencode-web" const hop = new Set([ "connection", "keep-alive", @@ -33,109 +28,28 @@ const hop = new Set([ "host", ]) -export const start = Effect.fn("cli.web-ui.start")(function* ( - endpoint: Ref.Ref, - options?: { readonly assets?: Readonly> }, -) { - const token = randomBytes(32).toString("base64url") - const origin = yield* listen(endpoint, { - auth: { type: "cookie", token }, - hostname: "127.0.0.1", - port: 0, - assets: options?.assets, - }) - return `${origin}/?cli_token=${encodeURIComponent(token)}` -}) - -export const serve = Effect.fn("cli.web-ui.serve")(function* ( - endpoint: Ref.Ref, - options: { - readonly hostname: string - readonly port?: number - readonly password: string - readonly assets?: Readonly> - }, -) { - return yield* listen(endpoint, { - auth: { type: "basic", password: options.password }, - hostname: options.hostname, - port: options.port, - assets: options.assets, - }) -}) - -const listen = Effect.fnUntraced(function* ( - endpoint: Ref.Ref, - options: { - readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string } - readonly hostname: string - readonly port?: number - readonly assets?: Readonly> - }, -) { - const assets = options.assets ?? (yield* Effect.promise(load)) - const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer)) - const websocket = yield* Socket.WebSocketConstructor.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS)) - const server = yield* bind(options.hostname, options.port) - const origin = formatAddress(server.http.address) - const urls = ServerInfo.connectionURLs(origin, options.hostname) - yield* server.http.serve( - handle({ endpoint, auth: options.auth, assets, client, websocket, origin, urls }), - ).pipe(Effect.provideService(Scope.Scope, server.scope)) - return origin -}) - -function handle(input: { - readonly endpoint: Ref.Ref - readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string } - readonly assets: Readonly> - readonly client: HttpClient.HttpClient - readonly websocket: Context.Service.Shape - readonly origin: string - readonly urls: ReadonlyArray +export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { + readonly assets?: Readonly> }) { - return Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, input.origin) - if (input.auth.type === "cookie" && request.headers.host !== new URL(input.origin).host) - return HttpServerResponse.empty({ status: 403 }) - const queryToken = url.searchParams.get(input.auth.type === "cookie" ? "cli_token" : "auth_token") - const queryAuthorized = input.auth.type === "cookie" && matches(queryToken, input.auth.token) - if (input.auth.type === "cookie" && queryToken !== null && request.headers.upgrade?.toLowerCase() !== "websocket") { - if (!queryAuthorized) return HttpServerResponse.empty({ status: 401 }) - url.searchParams.delete("cli_token") - return HttpServerResponse.empty({ - status: 302, - headers: { - location: url.pathname + url.search + url.hash, - "set-cookie": `${COOKIE}=${input.auth.token}; HttpOnly; SameSite=Strict; Path=/`, - "cache-control": "no-store", - }, - }) - } - if (input.auth.type === "cookie" && !queryAuthorized && !authorized(request.headers.cookie, input.auth.token)) - return unauthorized(false) - if ( - input.auth.type === "basic" && - !hasPtyTicket(url) && - !basicAuthorized(request.headers.authorization, queryToken, input.auth.password) + const assets = options?.assets ?? (yield* Effect.promise(load)) + const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer)) + return (api: Effect.Effect) => + api.pipe( + Effect.catchIf( + isRouteNotFound, + () => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => serveUI(client, request, new URL(request.url, "http://localhost"), assets)), + ), + ), ) - return unauthorized(true) - url.searchParams.delete("cli_token") - url.searchParams.delete("auth_token") - const requestOrigin = request.headers.host ? `http://${request.headers.host}` : input.origin - if (request.headers.origin !== undefined && request.headers.origin !== requestOrigin) - return HttpServerResponse.empty({ status: 403 }) +}) - if (url.pathname === "/api" || url.pathname.startsWith("/api/")) { - const endpoint = yield* Ref.get(input.endpoint) - const target = new URL(url.pathname + url.search, endpoint.url) - if (request.headers.upgrade?.toLowerCase() === "websocket") - return yield* proxyWebSocket(request, target, input.websocket) - return yield* proxyHttp(input.client, request, target, Service.headers(endpoint), false, input.urls) - } - return yield* serveUI(input.client, request, url, input.assets) - }) +export function url(endpoint: Endpoint) { + const target = new URL(endpoint.url) + if (endpoint.auth) + target.searchParams.set("auth_token", btoa(`${endpoint.auth.username}:${endpoint.auth.password}`)) + return target.toString() } function serveUI( @@ -146,7 +60,7 @@ function serveUI( ) { const key = url.pathname.replace(/^\//, "") const file = assets[key] ?? assets["index.html"] - if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM), undefined, true) + if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM)) if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 })) return Effect.tryPromise(() => readFile(file)).pipe( Effect.map((body) => { @@ -164,18 +78,11 @@ function serveUI( ) } -function proxyHttp( - client: HttpClient.HttpClient, - request: HttpServerRequest.HttpServerRequest, - target: URL, - extra: HeadersInit | undefined, - ui = false, - publicURLs?: ReadonlyArray, -) { +function proxyHttp(client: HttpClient.HttpClient, request: HttpServerRequest.HttpServerRequest, target: URL) { return client .execute( HttpClientRequest.make(request.method as never)(target, { - headers: proxyHeaders(request.headers, extra), + headers: proxyHeaders(request.headers), body: requestBody(request), }), ) @@ -185,9 +92,7 @@ function proxyHttp( headers.delete("content-encoding") headers.delete("content-length") headers.delete("set-cookie") - if (publicURLs && target.pathname === "/api/server") - return Effect.succeed(HttpServerResponse.jsonUnsafe({ urls: publicURLs }, { status: response.status })) - if (ui && response.headers["content-type"]?.includes("text/html")) { + if (response.headers["content-type"]?.includes("text/html")) { return response.text.pipe( Effect.map((body) => { headers.set("content-security-policy", cspForHtml(body)) @@ -196,7 +101,7 @@ function proxyHttp( }), ) } - if (ui) headers.set("content-security-policy", csp()) + headers.set("content-security-policy", csp()) return Effect.succeed( HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { status: response.status, @@ -208,34 +113,6 @@ function proxyHttp( ) } -function proxyWebSocket( - request: HttpServerRequest.HttpServerRequest, - target: URL, - websocket: Context.Service.Shape, -) { - target.protocol = target.protocol === "https:" ? "wss:" : "ws:" - return Effect.scoped( - Effect.gen(function* () { - const inbound = yield* Effect.orDie(request.upgrade) - const outbound = yield* Socket.makeWebSocket(target.toString(), { - protocols: protocols(request.headers["sec-websocket-protocol"]), - closeCodeIsError: () => false, - }).pipe(Effect.provideService(Socket.WebSocketConstructor, websocket)) - const writeInbound = yield* inbound.writer - const writeOutbound = yield* outbound.writer - const close = Effect.all( - [writeInbound(new Socket.CloseEvent()), writeOutbound(new Socket.CloseEvent())], - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void)) - yield* Effect.raceFirst( - outbound.runRaw((message) => writeInbound(typeof message === "string" ? message : message.slice())), - inbound.runRaw((message) => writeOutbound(typeof message === "string" ? message : message.slice())), - ).pipe(Effect.catch(() => Effect.void), Effect.ensuring(close)) - return HttpServerResponse.empty() - }).pipe(Effect.orDie), - ) -} - function requestBody(request: HttpServerRequest.HttpServerRequest) { if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty if (request.source instanceof Request && request.source.body === null) return HttpBody.empty @@ -243,55 +120,18 @@ function requestBody(request: HttpServerRequest.HttpServerRequest) { return HttpBody.stream(request.stream, request.headers["content-type"], length ? Number(length) : undefined) } -function proxyHeaders(input: Record, extra?: HeadersInit) { +function proxyHeaders(input: Record) { const headers = new Headers(input) for (const key of input.connection?.split(",").map((item) => item.trim()) ?? []) headers.delete(key) for (const key of hop) headers.delete(key) headers.delete("accept-encoding") headers.delete("authorization") headers.delete("cookie") - if (extra) for (const [key, value] of new Headers(extra)) headers.set(key, value) return headers } -function authorized(cookie: string | undefined, token: string) { - const value = cookie - ?.split(";") - .map((item) => item.trim().split("=")) - .find(([key]) => key === COOKIE)?.[1] - return matches(value ?? null, token) -} - -function basicAuthorized(header: string | undefined, queryToken: string | null, password: string) { - const expected = Buffer.from(`opencode:${password}`).toString("base64") - if (matches(queryToken, expected)) return true - if (!header?.startsWith("Basic ")) return false - return matches(header.slice("Basic ".length), expected) -} - -function hasPtyTicket(url: URL) { - return /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname) && !!url.searchParams.get("ticket") -} - -function unauthorized(basic: boolean) { - return HttpServerResponse.empty({ - status: 401, - headers: basic ? { "www-authenticate": 'Basic realm="Secure Area"' } : undefined, - }) -} - -function matches(value: string | null, expected: string) { - if (value === null) return false - const left = Buffer.from(value) - const right = Buffer.from(expected) - return left.length === right.length && timingSafeEqual(left, right) -} - -function protocols(value: string | undefined) { - return value - ?.split(",") - .map((item) => item.trim()) - .filter(Boolean) +function isRouteNotFound(error: unknown) { + return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound" } function csp(hash = "") { @@ -303,48 +143,4 @@ function cspForHtml(body: string) { return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") } -function bind(hostname: string, port: number | undefined) { - if (port !== undefined) return bindPort(hostname, port) - const next = (candidate: number): ReturnType => - bindPort(hostname, candidate).pipe( - Effect.catch((error) => - candidate < 65_535 && addressInUse(error) ? next(candidate + 1) : Effect.fail(error), - ), - ) - return next(4096) -} - -function bindPort(hostname: string, port: number) { - return Effect.gen(function* () { - const sockets = new Set<{ destroy(): void }>() - const server = createServer() - const scope = yield* Scope.make() - server.on("connection", (socket) => { - sockets.add(socket) - socket.once("close", () => sockets.delete(socket)) - }) - yield* Effect.addFinalizer(() => - Effect.sync(() => sockets.forEach((socket) => socket.destroy())).pipe( - Effect.andThen(Scope.close(scope, Exit.void)), - ), - ) - const http = yield* NodeHttpServer.make(() => server, { host: hostname, port }).pipe( - Effect.provideService(Scope.Scope, scope), - ) - return { http, scope } - }) -} - -function formatAddress(address: HttpServer.Address) { - if (address._tag === "UnixAddress") return HttpServer.formatAddress(address) - const hostname = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname - return `http://${hostname}:${address.port}` -} - -function addressInUse(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false - if ("code" in error && error.code === "EADDRINUSE") return true - return "cause" in error && addressInUse(error.cause) -} - export * as WebUi from "./web-ui" diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index 401da6d431..7137772ee1 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -1,240 +1,71 @@ +import { NodeHttpServer } from "@effect/platform-node" import { afterAll, describe, expect, test } from "bun:test" -import { WebUi } from "../src/services/web-ui" -import type { Endpoint } from "@opencode-ai/client/effect/service" -import { Effect, Ref } from "effect" +import { Effect } from "effect" +import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createServer } from "node:http" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" +import { WebUi } from "../src/services/web-ui" const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-")) afterAll(() => rm(root, { recursive: true, force: true })) -describe("TUI web UI", () => { - test("bootstraps a private browser session and proxies the current API endpoint", async () => { +describe("web UI", () => { + test("falls back from API routes to assets and the SPA index", async () => { const index = path.join(root, "index.html") + const asset = path.join(root, "app.js") await writeFile(index, "embedded") - const first = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => Response.json({ server: "first" }), - }) - const second = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => Response.json({ server: "second" }), - }) + await writeFile(asset, "console.log('embedded')") - try { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Ref.make({ url: first.url.toString() }) - const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } }) - const origin = new URL(launch).origin - - expect((yield* Effect.promise(() => fetch(origin))).status).toBe(401) - const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" })) - expect(bootstrap.status).toBe(302) - expect(bootstrap.headers.get("location")).toBe("/") - const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0] - expect(cookie).toStartWith("opencode-web=") - - const page = yield* Effect.promise(() => fetch(origin, { headers: { cookie: cookie ?? "" } })) - expect(yield* Effect.promise(() => page.text())).toContain("embedded") - expect(page.headers.get("content-security-policy")).toContain("default-src 'self'") - - const before = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } })) - expect(yield* Effect.promise(() => before.json())).toEqual({ server: "first" }) - yield* Ref.set(endpoint, { url: second.url.toString() }) - const after = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } })) - expect(yield* Effect.promise(() => after.json())).toEqual({ server: "second" }) - }), - ), - ) - } finally { - first.stop(true) - second.stop(true) - } - }) - - test("rejects foreign origins", async () => { - const index = path.join(root, "origin.html") - await writeFile(index, "embedded") await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" }) - const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } }) - const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" })) - const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0] - const response = yield* Effect.promise(() => - fetch(new URL(launch).origin, { - headers: { cookie: cookie ?? "", origin: "https://example.com" }, - }), - ) - expect(response.status).toBe(403) - }), - ), - ) - }) - - test("forwards websocket messages", async () => { - const index = path.join(root, "websocket.html") - await writeFile(index, "embedded") - const upstream = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch(request, server) { - if (server.upgrade(request)) return - return new Response(null, { status: 426 }) - }, - websocket: { - message(socket, message) { - socket.send(message) - }, - }, - }) - try { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Ref.make({ url: upstream.url.toString() }) - const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } }) - const target = new URL("/api/pty/test/connect?ticket=test", launch) - target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "") - target.protocol = "ws:" - const message = yield* Effect.promise( - () => - new Promise((resolve, reject) => { - const socket = new WebSocket(target) - socket.addEventListener("open", () => socket.send("hello"), { once: true }) - socket.addEventListener("message", (event) => { - resolve(event.data.toString()) - socket.close() - }, { once: true }) - socket.addEventListener("error", reject, { once: true }) - }), - ) - expect(message).toBe("hello") - }), - ), - ) - } finally { - upstream.stop(true) - } - }) - - test("serves foreground UI with server credentials", async () => { - const index = path.join(root, "serve.html") - await writeFile(index, "foreground") - const upstream = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: (request) => - new URL(request.url).pathname === "/api/server" - ? Response.json({ urls: ["http://private"] }) - : Response.json({ url: request.url }), - }) - try { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Ref.make({ - url: upstream.url.toString(), - auth: { type: "basic", username: "opencode", password: "private" }, - }) - const origin = yield* WebUi.serve(endpoint, { - hostname: "127.0.0.1", - port: 0, - password: "secret", - assets: { "index.html": index }, - }) - const denied = yield* Effect.promise(() => fetch(origin)) - expect(denied.status).toBe(401) - expect(denied.headers.get("www-authenticate")).toContain("Basic") - - const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}` - const page = yield* Effect.promise(() => fetch(origin, { headers: { authorization } })) - expect(yield* Effect.promise(() => page.text())).toContain("foreground") - - const token = Buffer.from("opencode:secret").toString("base64") - const query = yield* Effect.promise(() => fetch(`${origin}/?auth_token=${encodeURIComponent(token)}`)) - expect(query.status).toBe(200) - - const proxied = yield* Effect.promise(() => - fetch(`${origin}/api/health?auth_token=${encodeURIComponent(token)}&keep=yes`), - ) - const proxiedBody = yield* Effect.promise(() => proxied.json()) - expect(new URL(proxiedBody.url).search).toBe("?keep=yes") - - const info = yield* Effect.promise(() => fetch(`${origin}/api/server`, { headers: { authorization } })) - expect(yield* Effect.promise(() => info.json())).toEqual({ urls: [origin] }) - }), - ), - ) - } finally { - upstream.stop(true) - } - }) - - test("formats localhost listeners as valid URLs", async () => { - const index = path.join(root, "localhost.html") - await writeFile(index, "embedded") - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" }) - const origin = yield* WebUi.serve(endpoint, { - hostname: "localhost", - port: 0, - password: "secret", - assets: { "index.html": index }, - }) - expect(new URL(origin).protocol).toBe("http:") - }), - ), - ) - }) - - test("shuts down with an active websocket", async () => { - const index = path.join(root, "shutdown.html") - await writeFile(index, "embedded") - const upstream = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch(request, server) { - if (server.upgrade(request)) return - return new Response(null, { status: 426 }) - }, - websocket: { message() {} }, - }) - let socket: WebSocket | undefined - try { - const run = Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* Ref.make({ url: upstream.url.toString() }) - const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } }) - const target = new URL("/api/pty/test/connect?ticket=test", launch) - target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "") - target.protocol = "ws:" - socket = new WebSocket(target) - yield* Effect.promise( - () => new Promise((resolve, reject) => { - socket?.addEventListener("open", () => resolve(), { once: true }) - socket?.addEventListener("error", reject, { once: true }) + const transform = yield* WebUi.handler({ assets: { "index.html": index, "app.js": asset } }) + const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 }) + yield* http.serve( + transform( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const pathname = new URL(request.url, "http://localhost").pathname + if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true }) + if (pathname === "/api/missing") + return HttpServerResponse.jsonUnsafe({ code: "missing" }, { status: 404 }) + return yield* Effect.fail( + new HttpServerError.HttpServerError({ + reason: new HttpServerError.RouteNotFound({ request }), + }), + ) }), - ) - }), - ), - ) - await Promise.race([ - run, - new Promise((_, reject) => setTimeout(() => reject(new Error("web UI shutdown timed out")), 2_000)), - ]) - } finally { - socket?.close() - upstream.stop(true) - } + ), + ) + const origin = HttpServer.formatAddress(http.address) + + const health = yield* Effect.promise(() => fetch(`${origin}/api/health`)) + expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true }) + + const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`)) + expect(missing.status).toBe(404) + expect(yield* Effect.promise(() => missing.json())).toEqual({ code: "missing" }) + + const script = yield* Effect.promise(() => fetch(`${origin}/app.js`)) + expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')") + + const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`)) + expect(yield* Effect.promise(() => fallback.text())).toContain("embedded") + expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'") + }), + ), + ) + }) + + test("adds server credentials to browser URLs", () => { + const target = new URL( + WebUi.url({ + url: "http://localhost:4096", + auth: { type: "basic", username: "opencode", password: "secret" }, + }), + ) + expect(target.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) }) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 128685ed15..98a885a8d5 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -5,7 +5,13 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { + HttpMiddleware, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import { randomUUID } from "node:crypto" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -31,6 +37,8 @@ type App = Effect.Effect< HttpServerRequest.HttpServerRequest | Scope.Scope > +export type Transform = (app: App) => App + const errorResponseLogger = HttpMiddleware.make((app) => HttpMiddleware.logger( Effect.tap(app, (response) => @@ -42,6 +50,7 @@ const errorResponseLogger = HttpMiddleware.make((app) => export const start = Effect.fn("ServerProcess.start")(function* ( options: ServerOptions, lifecycle?: Lifecycle, + transform?: Transform, ) { const password = options.password if (!password) return yield* Effect.fail(new Error("Missing server password")) @@ -101,7 +110,8 @@ export const start = Effect.fn("ServerProcess.start")(function* ( Effect.provideService(Scope.Scope, applicationScope), ) } - yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect())) + const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready return { address: bound.http.address, shutdown: Deferred.await(shutdown) } }).pipe( diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index b462720bd3..71125a1d99 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -1,18 +1,28 @@ import { expect } from "bun:test" import { Effect } from "effect" -import { HttpServer } from "effect/unstable/http" +import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { - const server = yield* ServerProcess.start({ - hostname: "127.0.0.1", - port: 0, - password: "secret", - app: { version: "test-version" }, - database: { path: ":memory:" }, - }) + const server = yield* ServerProcess.start( + { + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + }, + undefined, + (api) => + api.pipe( + Effect.catchIf( + (error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound", + () => Effect.succeed(HttpServerResponse.text("fallback")), + ), + ), + ) const response = yield* Effect.promise(() => fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), { method: "OPTIONS", @@ -40,5 +50,13 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) + + const missing = yield* Effect.promise(() => + fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }), + ) + expect(missing.status).toBe(200) + expect(yield* Effect.promise(() => missing.text())).toBe("fallback") }), )