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
16 changes: 14 additions & 2 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {

declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; rootPath?: string }
}
}

Expand Down Expand Up @@ -102,7 +102,18 @@ export function AppInterface(props: { defaultUrl?: string }) {
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`

return window.location.origin
const rootPath = document.getElementById("root")?.dataset.rootPath || window.__OPENCODE__?.rootPath || ""

// Properly normalize URL to avoid duplicate slashes
if (!rootPath) return window.location.origin

try {
const normalized = new URL(rootPath, window.location.origin).toString()
// Remove trailing slash
return normalized.replace(/\/+$/, "")
} catch {
return (window.location.origin + rootPath).replace(/\/+$/, "")
}
}

return (
Expand All @@ -111,6 +122,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
base={document.getElementById("root")?.dataset.rootPath || window.__OPENCODE__?.rootPath}
root={(props) => (
<SettingsProvider>
<PermissionProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: "Invalid directory in URL.",
description: `Invalid directory in URL (${params.dir}).`,
})
navigate("/")
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const ServeCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const displayUrl = opts.rootPath ? new URL(opts.rootPath, `http://${server.hostname}:${server.port}`).toString() : `http://${server.hostname}:${server.port}`
console.log(`opencode server listening on ${displayUrl}`)
await new Promise(() => {})
await server.stop()
},
Expand Down
20 changes: 12 additions & 8 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,25 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
const baseUrl = opts.rootPath ? new URL(opts.rootPath, `http://localhost:${server.port}`).toString() : `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, baseUrl)

// Show network IPs for remote access
const networkIPs = getNetworkIPs()
if (networkIPs.length > 0) {
for (const ip of networkIPs) {
const networkUrl = opts.rootPath ? new URL(opts.rootPath, `http://${ip}:${server.port}`).toString() : `http://${ip}:${server.port}`
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
`http://${ip}:${server.port}`,
networkUrl,
)
}
}
Expand All @@ -68,14 +69,17 @@ export const WebCommand = cmd({
}

// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
open(baseUrl.toString()).catch(() => { })
} else {
const displayUrl = server.url.toString()
let displayUrl = server.url.toString()
if (opts.rootPath) {
displayUrl = new URL(opts.rootPath, server.url).toString()
}
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
open(displayUrl).catch(() => { })
}

await new Promise(() => {})
await new Promise(() => { })
await server.stop()
},
})
16 changes: 15 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const options = {
describe: "additional domains to allow for CORS",
default: [] as string[],
},
rootPath: {
type: "string" as const,
describe: "base path for reverse proxy",
default: "",
},
}

export type NetworkOptions = InferredOptionTypes<typeof options>
Expand All @@ -37,6 +42,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")
const rootPathExplicitlySet = process.argv.includes("--root-path")

const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
Expand All @@ -48,6 +54,14 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const configCors = config?.server?.cors ?? []
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
const rootPath = rootPathExplicitlySet ? args.rootPath : (config?.server?.rootPath ?? args.rootPath)

if (rootPath && !rootPath.startsWith("/")) {
throw new Error(
`Invalid rootPath: must start with '/' (got: '${rootPath}')\n` +
`Example: --root-path /jupyter/proxy/opencode`
)
}

return { hostname, port, mdns, cors }
return { hostname, port, mdns, cors, rootPath }
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ export namespace Config {
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
rootPath: z.string().optional().describe("Base path for reverse proxy"),
})
.strict()
.meta({
Expand Down
106 changes: 106 additions & 0 deletions packages/opencode/src/server/html-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Utilities for safely modifying HTML with rootPath injection
*/

import { Log } from "../util/log"

const log = Log.create({ service: "html-utils" })

/**
* Escapes special characters for safe use in HTML attributes
*/
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}

/**
* Safely injects rootPath configuration into index.html
*
* Security measures:
* - HTML attribute escaping prevents XSS via DOM attributes
* - JSON.stringify prevents XSS via script injection
* - Duplicate tag detection prevents configuration conflicts
*
* @param html - Original HTML content
* @param rootPath - Base path to inject (e.g., "/proxy")
* @returns Modified HTML with rootPath configuration, or original HTML on error
*
* @example
* const html = await Bun.file("index.html").text()
* const modified = injectRootPath(html, "/jupyter/proxy")
*/
export function injectRootPath(html: string, rootPath: string): string {
if (!rootPath) return html

try {
let modifiedHtml = html

// Check if base tag already exists
const hasBaseTag = /<base\s+href=/i.test(html)

// Safely escape rootPath for JSON injection (prevents XSS)
const safeRootPath = JSON.stringify(rootPath)

// Add base tag if it doesn't exist
if (!hasBaseTag) {
const baseTag = `<base href="${escapeHtmlAttribute(rootPath)}/">`
modifiedHtml = modifiedHtml.replace(/(<head[^>]*>)/i, `$1\n ${baseTag}`)
}

// Add script tag with safely escaped rootPath
const scriptTag = `<script>
console.log("OPENCODE: Injecting rootPath", ${safeRootPath});
window.__OPENCODE__ = window.__OPENCODE__ || {};
window.__OPENCODE__.rootPath = ${safeRootPath};
</script>`
modifiedHtml = modifiedHtml.replace(/(<head[^>]*>)/i, `$1\n ${scriptTag}`)

// Add data-root-path to root div if not already present
if (!/<div[^>]*id="root"[^>]*data-root-path=/i.test(modifiedHtml)) {
modifiedHtml = modifiedHtml.replace(
/(<div[^>]*id="root")/i,
`$1 data-root-path="${escapeHtmlAttribute(rootPath)}"`
)
}

return modifiedHtml
} catch (error) {
log.error("Failed to inject rootPath into HTML", { error })
return html // Return original HTML on error
}
}

/**
* Normalizes URL by removing duplicate slashes (except in protocol)
*/
export function normalizeUrl(baseUrl: string, path?: string): string {
if (!path) return baseUrl

try {
// Normalize path - remove leading extra slashes but keep one
const normalizedPath = path.replace(/^\/+/, "/")
const url = new URL(normalizedPath, baseUrl).toString()
// Replace multiple slashes with single slash, but preserve protocol://
return url.replace(/([^:]\/)\/+/g, "$1")
} catch (error) {
log.error("Failed to normalize URL", { error })
return baseUrl
}
}

/**
* Content Security Policy header value for serving HTML
*/
export const HTML_CSP_HEADER =
"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:"
Loading