Skip to content

feat: make opencode web embeddable in iframes at a subpath#23912

Open
csillag wants to merge 1 commit intoanomalyco:devfrom
csillag:csillag/make-web-embeddable-in-iframes
Open

feat: make opencode web embeddable in iframes at a subpath#23912
csillag wants to merge 1 commit intoanomalyco:devfrom
csillag:csillag/make-web-embeddable-in-iframes

Conversation

@csillag
Copy link
Copy Markdown

@csillag csillag commented Apr 23, 2026

Issue for this PR

No existing issue — self-motivated fix to enable embedding opencode web
inside an iframe under a reverse-proxy subpath. Happy to file one
retroactively if that's the workflow you prefer.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Lets opencode web work inside an <iframe> served from a URL subpath
(e.g. /some/deep/path/<session-id>/) under a reverse proxy. Three
small orthogonal changes, none of which affect a direct-at-root
deployment:

  1. packages/app/vite.config.tsbase: './'. The built index.html
    and chunk imports now use relative asset paths so a document loaded
    under a subpath resolves assets against the current URL instead of
    the origin root. Fixes "asset requests return the SPA fallback HTML
    with the wrong MIME type" for subpath embeds.

  2. packages/opencode/src/server/routes/ui.ts — add 'unsafe-eval' to
    the embedded-UI CSP's script-src. Something in the production
    bundle triggers eval() / new Function() at runtime; the existing
    policy allows 'wasm-unsafe-eval' but not 'unsafe-eval', so
    Firefox (stricter than Chromium here) blocks the bundle once it
    runs in an iframe. I didn't bisect the specific caller — if one of
    the maintainers has a guess I'm happy to chase and tighten the CSP
    back; this is the short-term unblocker.

  3. packages/app/src/entry.tsxgetCurrentUrl() now honors the
    localStorage.defaultServerUrl override the same way
    getDefaultUrl() already does. Before, servers[0].url was
    hard-wired to location.origin while defaultServer's key came
    from the override; the server-context resolver
    (allServers().find(key === state.active) ?? allServers()[0]) fell
    back to allServers()[0] when the two keys didn't align, and
    control-plane calls like /global/config and /global/event
    bypassed the override entirely. Aligning both entries fixes that.

The commit message on 88acdbc27 has the per-change rationale in more
depth.

How did you verify your code works?

Built from source (bun run --cwd packages/opencode build) and
installed opencode-linux-x64/bin/opencode on a Linux test box. Ran
the binary in two configurations:

  • Direct at its own port (no proxy). Dashboard loads, project picker
    works, session/prompt round-trip works, provider auth works. No
    behavior change vs. unmodified opencode.
  • Embedded in an iframe served by a reverse proxy under a deep URL
    path. Before these changes: asset requests resolved to the proxy's
    origin root (wrong MIME type, SPA-fallback HTML), CSP blocked eval
    when the SPA bundled code ran, and /global/* SDK calls went
    directly to location.origin ignoring the configured server URL.
    After these changes: iframe mounts, the SPA bundle executes, all
    SDK calls (including control-plane) route through the configured
    server URL via the proxy.

Screenshots / recordings

Not attached — happy to record if it'd help.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@csillag csillag requested a review from adamdotdevin as a code owner April 23, 2026 01:10
@csillag csillag force-pushed the csillag/make-web-embeddable-in-iframes branch from 88acdbc to 77df993 Compare April 23, 2026 01:14
csillag added a commit to deai-network/optio that referenced this pull request Apr 23, 2026
Follow-ups from the branch-level code review.  All low-risk fixes that
came up during verification/review of the otherwise-ready feature.

- RemoteHost.launch_opencode: read the opencode server password from a
  mode-0600 file in the workdir via `$(cat ...)` rather than inlining
  it into the command string.  Previously any local user on the remote
  host could read the basic-auth password from the bash process's argv
  via `ps aux` during the launch window.  Env variables (asyncssh
  create_process env=) would be cleaner but OpenSSH's default
  AcceptEnv won't forward arbitrary names, so a mode-0600 file the
  command substitutes at exec time is the portable fix.

- RemoteHost.terminate_opencode(aggressive=True): use proc.kill()
  (SIGKILL) instead of proc.terminate() (SIGTERM).  The spec's
  cancellation path expects SIGKILL; with SIGTERM an opencode that
  blocks on shutdown would eat the 5-second shutdown grace period.
  LocalHost was already correct.

- LocalHost.tail_log / RemoteHost.tail_log: document why we use
  `tail -F -n +1` instead of the spec's `-n 0`.  Fresh workdir + race-
  free at-least-once delivery (an earlier `-n 0` attempt silently
  dropped log lines that opencode wrote between subprocess spawn and
  our tail subscribing).

- Remove unused `aiofiles>=23.0` dependency (the module docstring
  mentioned it but the implementation spawns a `tail` subprocess;
  aiofiles was never imported).  Update host.py's module docstring.

- AGENTS.md: add a "Where the fork lives" paragraph pointing at
  github.com/csillag/opencode #csillag/make-web-embeddable-in-iframes,
  the upstream PR anomalyco/opencode#23912, and the build command so
  someone picking up this branch cold can populate
  OPTIO_OPENCODE_BINARY_DIR without hunting for the right repo.

Deferred review items (no change in this commit): remote-mode
cancellation test (spec Section 9 coverage gap), routing install
stderr through ctx.report_progress (nice-to-have), cosmetic
file-touch idiom, demo-task inner.execute clarification, SFTP-over-
SSH retry path not exercised by tests, negative-cache comment
consolidation.
@csillag csillag force-pushed the csillag/make-web-embeddable-in-iframes branch from 77df993 to e83eb50 Compare April 23, 2026 13:40
@csillag
Copy link
Copy Markdown
Author

csillag commented Apr 23, 2026

Sorry for the rebase hickup, now fixed and the change is only 16 lines.

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.
@csillag csillag force-pushed the csillag/make-web-embeddable-in-iframes branch from e83eb50 to 30bdc70 Compare April 24, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant