Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
915da3e
feat(server): add --base-path CLI flag and config option
fabiovincenzi May 19, 2026
0abe073
feat(server): base path URL rewriting and prefix stripping
fabiovincenzi May 19, 2026
735365c
feat(server): runtime HTML injection and CSP for base path
fabiovincenzi May 19, 2026
3ed473b
feat(app): base path support for router and API URLs
fabiovincenzi May 19, 2026
67f424d
fix(server): prevent XSS via script tag breakout in base path injection
fabiovincenzi Jun 1, 2026
9df3ba8
fix(server): preserve query string in base path redirect and harden a…
fabiovincenzi Jun 1, 2026
9dc842c
test(server): add base path unit tests
fabiovincenzi Jun 1, 2026
bd7fff5
merge upstream/dev into feat/base-path-support
fabiovincenzi Jun 11, 2026
0d337d9
fix(app): use router navigate instead of native <a> hrefs in titlebar
fabiovincenzi Jun 22, 2026
d6b9962
merge upstream/dev, resolve conflicts preserving base-path support
fabiovincenzi Jun 22, 2026
1ec0e57
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 22, 2026
413fd3d
fix(app): use only server-injected base path for router base
fabiovincenzi Jun 22, 2026
457254a
Merge branch 'feat/base-path-support' of https://github.com/fabiovinc…
fabiovincenzi Jun 22, 2026
089077d
feat(app): add document.baseURI fallback for proxy-injected base paths
fabiovincenzi Jun 26, 2026
08b53db
merge upstream/dev, resolve conflicts preserving base-path support
fabiovincenzi Jun 26, 2026
449d3bb
fix: use correct parameter name in CorsConfig
fabiovincenzi Jun 26, 2026
4f9538c
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 26, 2026
c1914bc
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 26, 2026
354c0f6
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Jul 1, 2026
c87e9ba
Merge branch 'feat/base-path-support' of https://github.com/fabiovinc…
fabiovincenzi Jul 1, 2026
469f7dd
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 1, 2026
01c10b4
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Jul 7, 2026
1d6b544
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 7, 2026
fb74b2d
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 9, 2026
9208645
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Aug 4, 2026
834d91d
Merge remote-tracking branch 'origin/feat/base-path-support' into fea…
fabiovincenzi Aug 4, 2026
081ef35
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Aug 4, 2026
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
1 change: 1 addition & 0 deletions models.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ export function AppInterface(props: {
</PermissionProvider>
</TabsProvider>
)}
base={(() => {
const bp = window.__OPENCODE_BASE_PATH__?.replace(/\/$/, "")
if (bp) return bp
const baseUri = new URL(document.baseURI).pathname.replace(/\/+$/, "")
return baseUri && baseUri !== "/" ? baseUri : undefined
})()}
>
<Routes serverScoped={props.serverScoped} />
</Dynamic>
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,17 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
}

const getCurrentUrl = () => {
let basePath = window.__OPENCODE_BASE_PATH__ || import.meta.env.VITE_OPENCODE_SERVER_BASE_URL || ""
if (basePath && !basePath.startsWith("/")) basePath = "/" + basePath
basePath = basePath.replace(/\/+$/, "")
if (!basePath) {
const baseUriPath = new URL(document.baseURI).pathname.replace(/\/+$/, "")
if (baseUriPath && baseUriPath !== "/") basePath = baseUriPath
}
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}${basePath}`
return location.origin + basePath
}

const getDefaultUrl = () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
interface ImportMetaEnv {
readonly BASE_URL: string
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
Expand All @@ -12,6 +13,12 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}

declare global {
interface Window {
__OPENCODE_BASE_PATH__?: string
}
}

declare module "*.png" {
const src: string
export default src
Expand Down
1 change: 1 addition & 0 deletions packages/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const sentry =
: false

export default defineConfig({
base: process.env.VITE_BASE_URL || "./",
plugins: [desktopPlugin, sentry] as any,
server: {
host: "0.0.0.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/v1/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ 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 hosting behind a reverse proxy (e.g., '/opencode')",
}),
}).annotate({ identifier: "ServerConfig" })
export type Server = Schema.Schema.Type<typeof Server>
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const ServeCommand = effectCmd({
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
console.log(`opencode server listening on http://${server.hostname}:${server.port}${server.basePath}`)

yield* Effect.never
}),
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export const WebCommand = effectCmd({
UI.println(UI.logo(" "))
UI.empty()

const suffix = server.basePath || ""
if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
const localhostUrl = `http://localhost:${server.port}${suffix}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)

// Show network IPs for remote access
Expand All @@ -58,7 +59,7 @@ export const WebCommand = effectCmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
`http://${ip}:${server.port}`,
`http://${ip}:${server.port}${suffix}`,
)
}
}
Expand All @@ -67,7 +68,7 @@ export const WebCommand = effectCmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`,
`${opts.mdnsDomain}:${server.port}${suffix}`,
)
}

Expand Down
20 changes: 19 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ const options = {
describe: "additional domains to allow for CORS",
default: [] as string[],
},
"base-path": {
type: "string" as const,
describe: "base URL path prefix for reverse proxy (e.g., /opencode)",
default: "",
},
}

export function normalizeBasePath(input: string | undefined): string {
if (!input) return ""
let p = input.trim()
if (!p || p === "/") return ""
if (!p.startsWith("/")) p = "/" + p
p = p.replace(/\/+$/, "")
return p
}

export type NetworkOptions = InferredOptionTypes<typeof options>
Expand Down Expand Up @@ -75,6 +89,10 @@ 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.some((a) => a === "--base-path" || a.startsWith("--base-path="))
const basePath = normalizeBasePath(
basePathExplicitlySet ? args["base-path"] : (config?.server?.basePath ?? args["base-path"]),
)

return { hostname, port, mdns, mdnsDomain, cors }
return { hostname, port, mdns, mdnsDomain, cors, basePath }
}
30 changes: 16 additions & 14 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,18 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
Layer.provide(authOnlyRouterLayer),
)

const uiRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
yield* router.add("*", "/*", (request) =>
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
)
}),
).pipe(Layer.provide(authOnlyRouterLayer))
function uiRouteWithBasePath(basePath?: string) {
return HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
yield* router.add("*", "/*", (request) =>
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi, basePath }),
)
}),
).pipe(Layer.provide(authOnlyRouterLayer))
}

type RouteRequirements =
| HttpRouter.HttpRouter
Expand Down Expand Up @@ -269,10 +271,10 @@ const app = LayerNode.group([
])

export function createRoutes(
corsOptions?: CorsOptions,
options?: CorsOptions & { basePath?: string },
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
const uiRoute = uiRouteWithBasePath(options?.basePath)
const locationServiceMapV2 = buildLocationServiceMap()

return Layer.mergeAll(
rootApiRoutes,
eventApiRoutes,
Expand All @@ -287,11 +289,11 @@ export function createRoutes(
compressionLayer,
corsVaryFix,
fenceLayer,
cors(corsOptions),
cors(options),
AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
HttpServer.layerServices,
]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provide(Layer.succeed(CorsConfig)(options)),
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(PtyEnvironment.layer),
Expand Down
51 changes: 45 additions & 6 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
import type { Duplex } from "node:stream"
import { MDNS } from "./mdns"
import { HttpApiApp } from "./routes/instance/httpapi/server"
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
Expand All @@ -21,6 +22,7 @@ export type Listener = {
hostname: string
port: number
url: URL
basePath: string
stop: (close?: boolean) => Promise<void>
}

Expand All @@ -34,14 +36,16 @@ type ListenOptions = CorsOptions & {
hostname: string
mdns?: boolean
mdnsDomain?: string
basePath?: string
}
type ListenerState = {
scope: Scope.Scope
server: Context.Service.Shape<typeof HttpServer.HttpServer>
http: ListenerServer
websockets: WebSocketTracker.Interface
}
type EffectListener = Omit<Listener, "stop"> & {
type EffectListener = Omit<Listener, "stop" | "basePath"> & {
basePath: string
stop: (close?: boolean) => Effect.Effect<void>
}

Expand Down Expand Up @@ -76,22 +80,25 @@ export async function listen(opts: ListenOptions): Promise<Listener> {
hostname: listener.hostname,
port: listener.port,
url: listener.url,
basePath: listener.basePath,
stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined),
}
}

const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(
function* (opts: ListenOptions) {
const basePath = opts.basePath ?? ""
const state = yield* startWithPortFallback(opts)
const address = yield* tcpAddress(state)
const listenerUrl = makeURL(opts.hostname, address.port)
const listenerUrl = makeURL(opts.hostname, address.port, basePath)
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
url = listenerUrl

return {
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
basePath,
stop: yield* makeStop(state, unpublishMdns, listenerUrl),
}
},
Expand All @@ -104,7 +111,7 @@ function listenerLayer(opts: ListenOptions, port: number) {
disableListenLog: true,
}).pipe(
Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname, basePath: opts.basePath })),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the
Expand Down Expand Up @@ -145,10 +152,11 @@ function tcpAddress(state: ListenerState) {
})
}

function makeURL(hostname: string, port: number) {
function makeURL(hostname: string, port: number, basePath = "") {
const result = new URL("http://localhost")
result.hostname = hostname
result.port = String(port)
if (basePath) result.pathname = basePath
return result
}

Expand Down Expand Up @@ -196,8 +204,39 @@ function forceClose(state: ListenerState) {
return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true })
}

function serverLayer(opts: { port: number; hostname: string }) {
function serverLayer(opts: { port: number; hostname: string; basePath?: string }) {
const server = createServer()
const bp = opts.basePath ?? ""

if (bp) {
const originalEmit = server.emit.bind(server)
server.emit = ((event: string, ...args: unknown[]) => {
if (event === "request" || event === "upgrade") {
const req = args[0] as IncomingMessage
const [pathname, query] = (req.url ?? "").split("?", 2)
if (pathname === bp && event === "request") {
const res = args[1] as ServerResponse
res.writeHead(301, { Location: bp + "/" + (query ? "?" + query : "") })
res.end()
return true
} else if (req.url && req.url.startsWith(bp + "/")) {
req.url = req.url.slice(bp.length) || "/"
} else {
if (event === "request") {
const res = args[1] as ServerResponse
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("Not Found")
} else {
const socket = args[1] as Duplex
socket.destroy()
}
return true
}
}
return originalEmit(event, ...args)
}) as typeof server.emit
}

const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server)
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
Expand Down
Loading
Loading