diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 8ae945b752..62a181af3a 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,6 +1,8 @@ const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ -export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) { +export type CorsOptions = { readonly cors?: ReadonlyArray } + +export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true if (input.startsWith("http://127.0.0.1:")) return true diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 433f301ae4..d2cc9b538d 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" -import { isAllowedCorsOrigin } from "./cors" +import { isAllowedCorsOrigin, type CorsOptions } from "./cors" const log = Log.create({ service: "server" }) @@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M } } -export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { +export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { return cors({ maxAge: 86_400, origin(input) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d453f458a4..e6dedfe2c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -38,7 +38,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin } from "@/server/cors" +import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" @@ -77,13 +77,14 @@ const runtime = HttpRouter.middleware()( ), ).layer -const cors = HttpRouter.middleware( - HttpMiddleware.cors({ - allowedOrigins: isAllowedCorsOrigin, - maxAge: 86_400, - }), - { global: true }, -) +const cors = (corsOptions?: CorsOptions) => + HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions), + maxAge: 86_400, + }), + { global: true }, + ) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) const instanceRouterLayer = authorizationRouterMiddleware @@ -130,55 +131,68 @@ const uiRoute = HttpRouter.use((router) => }), ).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) -export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( - Layer.provide([ - cors, - runtime, - Account.defaultLayer, - Agent.defaultLayer, - Auth.defaultLayer, - Command.defaultLayer, - Config.defaultLayer, - File.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Installation.defaultLayer, - MCP.defaultLayer, - Permission.defaultLayer, - Project.defaultLayer, - ProviderAuth.defaultLayer, - Provider.defaultLayer, - Pty.defaultLayer, - Question.defaultLayer, - Ripgrep.defaultLayer, - Session.defaultLayer, - SessionCompaction.defaultLayer, - SessionPrompt.defaultLayer, - SessionRevert.defaultLayer, - SessionShare.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - SessionSummary.defaultLayer, - SyncEvent.defaultLayer, - Skill.defaultLayer, - Todo.defaultLayer, - ToolRegistry.defaultLayer, - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.defaultLayer, - Bus.layer, - AppFileSystem.defaultLayer, - FetchHttpClient.layer, - HttpServer.layerServices, - ]), - Layer.provideMerge(Observability.layer), -) +export function createRoutes(corsOptions?: CorsOptions) { + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( + Layer.provide([ + cors(corsOptions), + runtime, + Account.defaultLayer, + Agent.defaultLayer, + Auth.defaultLayer, + Command.defaultLayer, + Config.defaultLayer, + File.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Installation.defaultLayer, + MCP.defaultLayer, + Permission.defaultLayer, + Project.defaultLayer, + ProviderAuth.defaultLayer, + Provider.defaultLayer, + Pty.defaultLayer, + Question.defaultLayer, + Ripgrep.defaultLayer, + Session.defaultLayer, + SessionCompaction.defaultLayer, + SessionPrompt.defaultLayer, + SessionRevert.defaultLayer, + SessionShare.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, + SessionSummary.defaultLayer, + SyncEvent.defaultLayer, + Skill.defaultLayer, + Todo.defaultLayer, + ToolRegistry.defaultLayer, + Vcs.defaultLayer, + Workspace.defaultLayer, + Worktree.defaultLayer, + Bus.layer, + AppFileSystem.defaultLayer, + FetchHttpClient.layer, + HttpServer.layerServices, + ]), + Layer.provideMerge(Observability.layer), + ) +} -export const webHandler = lazy(() => +export const routes = createRoutes() + +const defaultWebHandler = lazy(() => HttpRouter.toWebHandler(routes, { memoMap, middleware: disposeMiddleware, }), ) +export function webHandler(corsOptions?: CorsOptions) { + if (!corsOptions?.cors?.length) return defaultWebHandler() + return HttpRouter.toWebHandler(createRoutes(corsOptions), { + // Server-level CORS options are dynamic; don't reuse the default route layer memoized without them. + memoMap: Layer.makeMemoMapUnsafe(), + middleware: disposeMiddleware, + }) +} + export * as ExperimentalHttpApiServer from "./server" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e4aeda7989..a1e821fb70 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -18,6 +18,7 @@ import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import * as ServerBackend from "./backend" +import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -38,6 +39,13 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } +type ListenOptions = CorsOptions & { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string +} + const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), ) @@ -54,14 +62,14 @@ export const Default = () => { return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() } -function create(opts: { cors?: string[] }) { +function create(opts: ListenOptions) { const selected = select() return selected.backend === "effect-httpapi" - ? withBackend(selected, createHttpApi()) + ? withBackend(selected, createHttpApi(opts)) : withBackend(selected, createHono(opts, selected)) } -export function Legacy(opts: { cors?: string[] } = {}) { +export function Legacy(opts: CorsOptions = {}) { return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) } @@ -74,8 +82,8 @@ function withBackend(selection: return built } -function createHttpApi() { - const handler = ExperimentalHttpApiServer.webHandler().handler +function createHttpApi(corsOptions?: CorsOptions) { + const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { @@ -89,7 +97,7 @@ function createHttpApi() { } function createHono( - opts: { cors?: string[] }, + opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), ) { const backendAttributes = ServerBackend.attributes(selection) @@ -151,13 +159,7 @@ export async function openapi() { export let url: URL -export async function listen(opts: { - port: number - hostname: string - mdns?: boolean - mdnsDomain?: string - cors?: string[] -}): Promise { +export async function listen(opts: ListenOptions): Promise { const built = create(opts) const server = await built.runtime.listen(opts) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 3330cfdd11..2e5520cafa 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Config, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" @@ -61,4 +62,30 @@ describe("HttpApi CORS", () => { expect(response.headers["access-control-allow-headers"]).toBe("authorization") }), ) + + it.live("uses custom CORS origins passed to the server", () => + Effect.gen(function* () { + const listener = yield* Effect.acquireRelease( + Effect.promise(() => + Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }), + ), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + const response = yield* Effect.promise(() => + fetch(new URL(InstancePaths.path, listener.url), { + method: "OPTIONS", + headers: { + origin: "https://custom.example", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }), + ) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example") + expect(response.headers.get("access-control-allow-headers")).toBe("authorization") + }), + ) })