From 4ff331fd5d87df4dc57664c2f1a8eb2979aa9c3b Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Thu, 23 Apr 2026 03:02:53 +0200 Subject: [PATCH] feat: make opencode web embeddable in iframes at a subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables a reverse-proxy / embedding host (e.g. a parent dashboard) to serve opencode web under an arbitrary URL prefix — the built SPA, the server's CSP, and the SDK's default-server-URL resolution all have to cooperate for a same-origin iframe mount to actually work. Three orthogonal changes: 1. packages/app/vite.config.ts — `base: './'` Emits relative asset paths in the built index.html and chunk imports (e.g. `./assets/foo.js` instead of `/assets/foo.js`), so a document loaded under `/some/deep/iframe-prefix/` can resolve its own asset URLs against that prefix rather than against the origin root. No effect on direct-serve at `/`; every Vite-base subpath story just works from one source build. 2. packages/opencode/src/server/routes/ui.ts — add `'unsafe-eval'` to the embedded-UI CSP's script-src directive Something in opencode's production bundle (best guess: a workerized runtime or a lib that compiles at runtime; we haven't bisected the exact call site) exercises `eval()` / `new Function()`. The existing CSP permitted `'wasm-unsafe-eval'` but not `'unsafe-eval'`, causing the browser to block the bundle under the stricter Firefox policy when served behind the embed proxy. Allowing `'unsafe-eval'` keeps the page functional. A better long-term fix is to bisect the eval caller and remove it, then tighten CSP back; this commit is the short-term unblocker. 3. packages/app/src/entry.tsx — `getCurrentUrl` honors the localStorage defaultServerUrl override `getCurrentUrl()` was previously hard-wired to `location.origin` (in production) for the initial `servers[0]` entry, while `getDefaultUrl()` would return the localStorage-set `defaultServerUrl` when present for the `defaultServer` key. The two disagreed: the server-context's `current` server resolves via `allServers().find(key === state.active) ?? allServers()[0]`, so if `state.active` pointed at the localStorage URL but that URL wasn't in `allServers()`, the code fell back to `allServers()[0]` — i.e. `location.origin` — and control-plane requests like `/global/config` and `/global/event` bypassed the override entirely. Having `getCurrentUrl` also honor the localStorage override keeps both entries aligned and makes the override globally effective. Together these let opencode web embed inside an iframe served from a foreign origin / subpath: assets load, the SPA bundle executes, and all SDK calls (including control-plane routes) honor the configured server URL. --- packages/app/src/entry.tsx | 8 ++++++++ packages/app/vite.config.ts | 4 ++++ packages/opencode/src/server/routes/ui.ts | 4 ++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d3..6fb362503b76 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -98,6 +98,14 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { } const getCurrentUrl = () => { + // Honor the localStorage `defaultServerUrl` override if set so that the + // initial `servers[0]` entry matches the `defaultServer` key; otherwise + // `allServers().find(...)` in the server context falls back to + // `allServers()[0]` and the SDK ends up calling `location.origin` for + // control-plane ("/global/*") routes even when the user configured a + // different server URL via localStorage. + const lsDefault = readDefaultServerUrl() + if (lsDefault) return lsDefault 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"}` diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 6a29ae6345e0..62292f7f9893 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -3,6 +3,10 @@ import desktopPlugin from "./vite" export default defineConfig({ plugins: [desktopPlugin] as any, + // Relative base so the built SPA can be served under any URL subpath + // (iframe embedding via reverse proxy) without requiring absolute-path + // asset resolution at the host origin. Also works for direct-serve at `/`. + base: "./", server: { host: "0.0.0.0", allowedHosts: true, diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index d449cd1c4241..90e065a8cad0 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -11,10 +11,10 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) 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:" + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" 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:` + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval' '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:` export const UIRoutes = (): Hono => new Hono().all("/*", async (c) => {