From b70c0ec32579fb4f3a46303c105a3a038ab4aa3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:23:42 +0000 Subject: [PATCH 1/2] Initial plan From 42b7b9fa9a03231f671d9aacf16144adb0e797b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:27:06 +0000 Subject: [PATCH 2/2] Implement P0-P2 rootPath improvements with fallback strategy Co-authored-by: DaehoYang <129835752+DaehoYang@users.noreply.github.com> --- packages/opencode/src/cli/network.ts | 5 +- packages/opencode/src/server/html-utils.ts | 16 ++- packages/opencode/src/server/server.ts | 110 +++++++++++++++--- .../opencode/test/server/rootpath.test.ts | 88 ++++++++++++++ packages/web/src/content/docs/server.mdx | 30 ++++- 5 files changed, 228 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 5df16f68fa7a..507848571e8c 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -57,7 +57,10 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const rootPath = rootPathExplicitlySet ? args.rootPath : (config?.server?.rootPath ?? args.rootPath) if (rootPath && !rootPath.startsWith("/")) { - throw new Error(`rootPath must start with '/' if provided (got: '${rootPath}')`) + throw new Error( + `Invalid rootPath: must start with '/' (got: '${rootPath}')\n` + + `Example: --root-path /jupyter/proxy/opencode` + ) } return { hostname, port, mdns, cors, rootPath } diff --git a/packages/opencode/src/server/html-utils.ts b/packages/opencode/src/server/html-utils.ts index db00e1e4cdbe..cd962905bc0a 100644 --- a/packages/opencode/src/server/html-utils.ts +++ b/packages/opencode/src/server/html-utils.ts @@ -20,9 +20,19 @@ function escapeHtmlAttribute(value: string): string { /** * Safely injects rootPath configuration into index.html - * - Prevents XSS by properly escaping values - * - Checks for existing tags to avoid duplication - * - Returns modified HTML or original on any error + * + * 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 diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 69002e4263df..ab3d5a949264 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -48,6 +48,11 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + // Constants for paths and URLs + const APP_DIST_PATH = "../app/dist" + const APP_INDEX_PATH = `${APP_DIST_PATH}/index.html` + const REMOTE_PROXY_URL = "https://app.opencode.ai" + let _url: URL | undefined let _corsWhitelist: string[] = [] let _rootPath: string = "" @@ -532,7 +537,7 @@ export namespace Server { }) }, ) - .use("/*", serveStatic({ root: "../app/dist" })) as unknown as Hono, + .use("/*", serveStatic({ root: APP_DIST_PATH })) as unknown as Hono, ) export async function openapi() { @@ -550,6 +555,42 @@ export namespace Server { return result } + /** + * Creates a handler that serves static files locally if available, + * otherwise falls back to remote proxy + */ + async function createStaticOrProxyHandler() { + const indexFile = Bun.file(APP_INDEX_PATH) + const localAppExists = await indexFile.exists() + + if (localAppExists) { + log.info("πŸ“¦ Serving app from local build (../app/dist)") + return { + type: "local" as const, + handler: serveStatic({ root: APP_DIST_PATH }) + } + } else { + log.warn("🌐 Local app build not found, falling back to remote proxy (https://app.opencode.ai)") + log.warn(" For better performance, build the app: cd packages/app && bun run build") + + return { + type: "proxy" as const, + handler: async (c: any) => { + const path = c.req.path + const response = await proxy(`${REMOTE_PROXY_URL}${path}`, { + ...c.req, + headers: { + ...c.req.raw.headers, + host: "app.opencode.ai", + }, + }) + response.headers.set("Content-Security-Policy", HTML_CSP_HEADER) + return response + } + } + } + } + /** * Creates a handler that serves index.html with rootPath injection * Centralizes HTML serving logic to avoid duplication @@ -557,7 +598,7 @@ export namespace Server { function createIndexHandler(rootPath: string) { return async (c: any) => { try { - const indexFile = Bun.file("../app/dist/index.html") + const indexFile = Bun.file(APP_INDEX_PATH) if (!(await indexFile.exists())) { log.warn("index.html not found at ../app/dist/index.html") return c.text("Not Found", 404) @@ -576,12 +617,59 @@ export namespace Server { } } - export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) { + /** + * Creates app with common routes to avoid duplication + */ + function createAppWithRoutes( + indexHandler: (c: any) => Promise, + staticHandler: any, + apiApp: Hono + ): Hono { + return new Hono() + .route("/", apiApp) + .get("/", indexHandler) + .get("/index.html", indexHandler) + .use("/*", staticHandler) + .all("/*", indexHandler) as unknown as Hono + } + + /** + * Starts the OpenCode HTTP server + * + * @param opts.rootPath - Base path for reverse proxy deployment (e.g., "/jupyter/proxy/opencode") + * When provided, requires local app build. Without it, falls back to remote proxy. + * + * @example + * // Standard mode (auto fallback) + * listen({ port: 4096, hostname: "localhost" }) + * + * @example + * // Reverse proxy mode (requires local build) + * listen({ port: 4096, hostname: "0.0.0.0", rootPath: "/proxy" }) + * + * @throws {Error} If rootPath is provided but local app build is missing + */ + export async function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) { _corsWhitelist = opts.cors ?? [] _rootPath = opts.rootPath ?? "" + // rootPath requires local build for reliable routing + if (opts.rootPath) { + const localAppExists = await Bun.file(APP_INDEX_PATH).exists() + if (!localAppExists) { + throw new Error( + "rootPath requires local app build.\n" + + "Build the app first: cd packages/app && bun run build\n" + + "Or run without --root-path to use remote proxy." + ) + } + } + + const { type: serveType, handler: staticHandler } = await createStaticOrProxyHandler() + // Create single index handler (no duplication!) const indexHandler = createIndexHandler(_rootPath) + const apiApp = App() // Setup routing based on whether rootPath is provided let baseApp: Hono @@ -591,24 +679,16 @@ export namespace Server { // This ensures all routes including WebSocket work correctly const rootedApp = new Hono() .basePath(opts.rootPath) - .route("/", App()) - .get("/", indexHandler) - .get("/index.html", indexHandler) - .use("/*", serveStatic({ root: "../app/dist" })) - .all("/*", indexHandler) // SPA fallback - + .route("/", createAppWithRoutes(indexHandler, staticHandler, apiApp)) + // Root app to handle both rooted and global asset paths baseApp = new Hono() .route("/", rootedApp) // Serve static assets that may use absolute paths (e.g., /assets/...) - .use("/*", serveStatic({ root: "../app/dist" })) + .use("/*", staticHandler) } else { // Standard setup without rootPath - baseApp = App() - .get("/", indexHandler) - .get("/index.html", indexHandler) - .use("/*", serveStatic({ root: "../app/dist" })) - .all("/*", indexHandler) as unknown as Hono + baseApp = createAppWithRoutes(indexHandler, staticHandler, apiApp) } const args = { diff --git a/packages/opencode/test/server/rootpath.test.ts b/packages/opencode/test/server/rootpath.test.ts index e55b974443d6..be1de3b72e33 100644 --- a/packages/opencode/test/server/rootpath.test.ts +++ b/packages/opencode/test/server/rootpath.test.ts @@ -154,3 +154,91 @@ describe("server URL with rootPath", () => { expect(finalUrl).toBe("http://localhost:4096/") }) }) + +describe("Special character handling", () => { + test("handles URL encoded characters", () => { + const html = '
' + const result = injectRootPath(html, "/ν•œκΈ€/경둜") + + // Should properly escape in HTML attributes + expect(result).toContain('data-root-path=') + // Should safely encode in JavaScript + expect(result).toContain('window.__OPENCODE__.rootPath') + }) + + test("handles spaces and special chars in rootPath", () => { + const html = '
' + const paths = ["/path with space", "/path-with-dash", "/path_with_underscore", "/path.with.dot"] + + for (const path of paths) { + const result = injectRootPath(html, path) + expect(result).toContain(JSON.stringify(path)) + } + }) + + test("handles paths with query-like characters", () => { + const maliciousPath = "/proxy?token=abc&key=xyz" + const html = '
' + const result = injectRootPath(html, maliciousPath) + + // Should be safely escaped + expect(result).toContain(JSON.stringify(maliciousPath)) + }) +}) + +describe("URL normalization edge cases", () => { + test("handles multiple consecutive slashes", () => { + expect(normalizeUrl("http://localhost:4096", "///proxy///path///")).toBe( + "http://localhost:4096/proxy/path/" + ) + }) + + test("handles mixed slash patterns", () => { + expect(normalizeUrl("http://localhost:4096/", "//proxy/path")).toBe( + "http://localhost:4096/proxy/path" + ) + }) + + test("preserves trailing slash when explicitly provided", () => { + const result = normalizeUrl("http://localhost:4096", "/proxy/") + expect(result.endsWith("/")).toBe(true) + }) +}) + +describe("WebSocket compatibility", () => { + test("WebSocket URL construction with rootPath", () => { + const serverUrl = "http://localhost:4096" + const rootPath = "/jupyter/proxy/opencode" + + // WebSocket should use same base path + const wsUrl = new URL(rootPath, serverUrl) + wsUrl.protocol = "ws:" + + expect(wsUrl.toString()).toBe("ws://localhost:4096/jupyter/proxy/opencode") + }) + + test("WebSocket URL without rootPath", () => { + const serverUrl = "http://localhost:4096" + const wsUrl = new URL(serverUrl) + wsUrl.protocol = "ws:" + + expect(wsUrl.toString()).toBe("ws://localhost:4096/") + }) +}) + +describe("Fallback strategy", () => { + test("validates fallback behavior when local build missing", () => { + // This test documents expected behavior + const scenarios = [ + { hasLocalBuild: true, hasRootPath: false, expected: "local" }, + { hasLocalBuild: false, hasRootPath: false, expected: "proxy" }, + { hasLocalBuild: true, hasRootPath: true, expected: "local" }, + { hasLocalBuild: false, hasRootPath: true, expected: "error" }, + ] + + for (const scenario of scenarios) { + // Expected behavior documented + expect(scenario.expected).toBeDefined() + } + }) +}) diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 92510c214c09..1e2e694bfa76 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -24,7 +24,7 @@ opencode serve [--port ] [--hostname ] [--cors ] [--root | `--hostname` | Hostname to listen on | `127.0.0.1` | | `--mdns` | Enable mDNS discovery | `false` | | `--cors` | Additional browser origins to allow | `[]` | -| `--root-path` | Base path for reverse proxy | (empty) | +| `--root-path` | Base path for reverse proxy | (empty) | `--cors` can be passed multiple times: @@ -32,12 +32,38 @@ opencode serve [--port ] [--hostname ] [--cors ] [--root opencode serve --cors http://localhost:5173 --cors https://app.example.com ``` -Use `--root-path` when running behind a reverse proxy: +--- + +### Deployment Modes + +OpenCode server supports two deployment modes: + +1. **Standard Mode** (default) + - Serves app from local build if available + - Falls back to remote proxy (https://app.opencode.ai) if local build missing + - Best for development and simple deployments + +2. **Reverse Proxy Mode** (with `--root-path`) + - Requires local app build (no fallback) + - All routes prefixed with specified path + - Best for Jupyter, corporate proxies, and multi-tenant environments ```bash +# Standard mode with auto-fallback +opencode serve + +# Behind reverse proxy (requires: cd packages/app && bun run build) opencode serve --root-path /jupyter/proxy/opencode ``` +⚠️ **Note**: When using `--root-path`, ensure the app is built first: +```bash +cd packages/app +bun run build +cd ../../packages/opencode +opencode serve --root-path /your/path +``` + --- ### Authentication