From ba8ff9f6c9d50785d5a0f6c7768d478eaece1f19 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Mon, 4 May 2026 16:58:49 -0600 Subject: [PATCH] feat: add base path support for reverse proxy deployments Adds OPENCODE_SERVER_BASE_PATH env var, --base-path CLI flag, and server.basePath config option. When set, injects a tag into HTML responses so asset references resolve correctly when opencode web is served behind a path-prefixed reverse proxy. The SolidJS Router is also given the base path so client-side navigation stays within the prefix. --- packages/app/src/app.tsx | 2 ++ packages/app/src/entry.tsx | 3 +++ packages/core/src/flag/flag.ts | 3 +++ packages/opencode/src/cli/network.ts | 11 +++++++++- packages/opencode/src/config/server.ts | 4 ++++ packages/opencode/src/server/routes/ui.ts | 26 +++++++++++++++-------- packages/opencode/src/server/shared/ui.ts | 15 ++++++++++--- 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3189d80257df..fc683d4ab4bf 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -297,6 +297,7 @@ export function AppInterface(props: { defaultServer: ServerConnection.Key servers?: Array router?: Component + base?: string disableHealthCheck?: boolean }) { return ( @@ -312,6 +313,7 @@ export function AppInterface(props: { {routerProps.children}} > diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 5115f0348ad4..b65db9938e37 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -164,6 +164,8 @@ if (root instanceof HTMLElement) { ...auth, }, } + const baseEl = document.querySelector("base") + const base = baseEl ? new URL(baseEl.href).pathname : undefined render( () => ( @@ -171,6 +173,7 @@ if (root instanceof HTMLElement) { diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800c1..8d1e9676f562 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -56,6 +56,9 @@ export const Flag = { OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], + get OPENCODE_SERVER_BASE_PATH() { + return process.env["OPENCODE_SERVER_BASE_PATH"] + }, OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"), // Experimental diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index a6cecdfacdc6..0564929c2a34 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -29,6 +29,10 @@ const options = { describe: "additional domains to allow for CORS", default: [] as string[], }, + "base-path": { + type: "string" as const, + describe: "base path prefix for the web UI when served behind a reverse proxy", + }, } export type NetworkOptions = InferredOptionTypes @@ -57,6 +61,11 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const configCors = config?.server?.cors ?? [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] + const basePathExplicitlySet = process.argv.includes("--base-path") + const basePath = basePathExplicitlySet ? args["base-path"] : (config?.server?.basePath ?? args["base-path"]) + if (basePath && !process.env["OPENCODE_SERVER_BASE_PATH"]) { + process.env["OPENCODE_SERVER_BASE_PATH"] = basePath + } - return { hostname, port, mdns, mdnsDomain, cors } + return { hostname, port, mdns, mdnsDomain, cors, basePath } } diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f1369826942..dd0413a0c3fd 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -14,6 +14,10 @@ export const Server = Schema.Struct({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), + basePath: Schema.optional(Schema.String).annotate({ + description: + "Base path prefix for the web UI (e.g. /notebook-sessions/abc/ports/4096). Use when opencode is served behind a reverse proxy that adds a path prefix.", + }), }) .annotate({ identifier: "ServerConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index ce06b2b35ee1..d8172290492e 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises" import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" import { Hono } from "hono" import { proxy } from "hono/proxy" import { ProxyUtil } from "../proxy-util" -import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" +import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, injectBasePath, themePreloadHash, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -17,10 +18,12 @@ export async function serveUI(request: Request) { if (await fs.exists(match)) { const mime = AppFileSystem.mimeType(match) 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 }) - } - + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", DEFAULT_CSP) + const html = new TextDecoder().decode(await fs.readFile(match)) + const basePath = Flag.OPENCODE_SERVER_BASE_PATH + return new Response(basePath ? injectBasePath(html, basePath) : html, { headers }) + } return Response.json({ error: "Not Found" }, { status: 404 }) } @@ -28,12 +31,17 @@ export async function serveUI(request: Request) { 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 isHtml = response.headers.get("content-type")?.includes("text/html") + if (!isHtml) { + response.headers.set("Content-Security-Policy", csp()) + return response + } + const body = await response.text() + const match = themePreloadHash(body) const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" response.headers.set("Content-Security-Policy", csp(hash)) - return response + const basePath = Flag.OPENCODE_SERVER_BASE_PATH + return new Response(basePath ? injectBasePath(body, basePath) : body, { status: response.status, headers: response.headers }) } export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index 40d8aa7afb02..e4df18b3de74 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -14,6 +14,11 @@ export 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:" export const UI_UPSTREAM = new URL("https://app.opencode.ai") +export function injectBasePath(html: string, basePath: string): string { + const href = basePath.endsWith("/") ? basePath : `${basePath}/` + return html.replace(/(]*>)/i, `$1\n `) +} + export 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:` @@ -53,8 +58,11 @@ function notFound() { function embeddedUIResponse(file: string, body: Uint8Array) { const mime = AppFileSystem.mimeType(file) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(body, { headers }) + if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers }) + headers.set("content-security-policy", DEFAULT_CSP) + const html = new TextDecoder().decode(body) + const basePath = Flag.OPENCODE_SERVER_BASE_PATH + return HttpServerResponse.text(basePath ? injectBasePath(html, basePath) : html, { headers }) } export function serveEmbeddedUIEffect( @@ -93,7 +101,8 @@ export function serveUIEffect( 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 }) + const basePath = Flag.OPENCODE_SERVER_BASE_PATH + return HttpServerResponse.text(basePath ? injectBasePath(body, basePath) : body, { status: response.status, headers }) } headers.set("Content-Security-Policy", csp())