Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export function AppInterface(props: {
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
base?: string
disableHealthCheck?: boolean
}) {
return (
Expand All @@ -312,6 +313,7 @@ export function AppInterface(props: {
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
base={props.base}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,16 @@ if (root instanceof HTMLElement) {
...auth,
},
}
const baseEl = document.querySelector("base")
const base = baseEl ? new URL(baseEl.href).pathname : undefined
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]}
base={base}
disableHealthCheck
/>
</AppBaseProviders>
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof options>
Expand Down Expand Up @@ -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 }
}
4 changes: 4 additions & 0 deletions packages/opencode/src/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })))
Expand Down
26 changes: 17 additions & 9 deletions packages/opencode/src/server/routes/ui.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -17,23 +18,30 @@ 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 })
}

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 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))
15 changes: 12 additions & 3 deletions packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(<head\b[^>]*>)/i, `$1\n <base href="${href}">`)
}

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:`

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand Down
Loading