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. Four
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. The production bundle invokes
    new Function(...) at runtime: Zod 4 ([email protected] via the workspace
    catalog) JIT-compiles its validators at schema-definition time,
    which is where v4's speed advantage over v3 comes from. Every
    z.object({...}) in the bundle therefore needs 'unsafe-eval';
    the existing policy allowed 'wasm-unsafe-eval' but not the
    broader keyword, so Firefox (stricter than Chromium here)
    correctly blocked the bundle once it ran in an iframe. Confirmed
    by tracing — the first Function-construct trap at page load
    fires from packages/app/src/context/global-sdk.tsx:12
    (z.object({ name: z.literal('AbortError') })) and the stack
    walks through zod/v4/core/{core,schemas,util}.js, the v4
    codegen pipeline. The relaxation is therefore not optional while
    we depend on zod 4 — alternatives are switching the app package
    to zod/mini (no-codegen entry, restricted API) or downgrading
    to zod 3, both larger refactors out of scope here.

  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.

  4. packages/opencode/src/server/routes/ui.ts — extend connect-src
    to allow https://opencode.ai. packages/app/src/context/highlights.tsx
    fetches the release changelog from https://opencode.ai/changelog.json
    to surface release highlights. Under the previous
    connect-src 'self' data:, the fetch was blocked once the SPA ran
    from a non-opencode.ai origin (i.e. exactly the embed case). The
    expanded directive keeps the changelog feature working in
    embedded deployments and is consistent with the SPA's existing
    same-domain trust for that origin (location.hostname.includes('opencode.ai')
    in entry.tsx, and the production fallback proxy in ui.ts).

The commit message on 686a8dd63 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.

@csillag csillag force-pushed the csillag/make-web-embeddable-in-iframes branch 3 times, most recently from 4ff331f to 686a8dd Compare April 25, 2026 22:07
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.  Four
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

   Zod 4 (pinned via the workspace catalog at `[email protected]`) JIT-compiles
   its validators at schema-definition time via `new Function(...)` —
   that's where v4's speed advantage over v3 comes from.  Every
   `z.object({...})` in the bundle therefore needs `'unsafe-eval'`;
   the existing CSP only granted `'wasm-unsafe-eval'`, which Firefox
   correctly distinguishes from the broader keyword Zod actually
   needs.  Confirmed by tracing: the first Function-construct trap at
   page load fires from `packages/app/src/context/global-sdk.tsx:12`
   (`z.object({ name: z.literal('AbortError') })`) and the stack
   walks through `zod/v4/core/{core,schemas,util}.js`, which is the
   v4 codegen pipeline.  This relaxation is therefore not optional
   while we depend on zod 4 — the alternatives are switching the
   `app` package to `zod/mini` (no-codegen entry, restricted API) or
   downgrading to zod 3, both larger refactors.

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.

4. packages/opencode/src/server/routes/ui.ts — extend `connect-src`
   to allow `https://opencode.ai`

   `packages/app/src/context/highlights.tsx` fetches the release
   changelog from `https://opencode.ai/changelog.json` to surface
   release highlights to the user.  Under the previous
   `connect-src 'self' data:` the fetch was blocked once the SPA
   ran from a non-opencode.ai origin (i.e. exactly the embed case).
   Adding `https://opencode.ai` to `connect-src` lets the changelog
   feature keep working in embedded deployments and is consistent
   with the SPA's existing same-domain trust (the production
   fallback proxy and the `location.hostname.includes('opencode.ai')`
   logic in `entry.tsx` already treat that origin as canonical).

Together these let opencode web embed inside an iframe served from a
foreign origin / subpath: assets load, the SPA bundle executes, all
SDK calls (including control-plane routes) honor the configured
server URL, and the changelog fetch isn't blocked by CSP.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@csillag csillag force-pushed the csillag/make-web-embeddable-in-iframes branch from 686a8dd to 6488100 Compare April 25, 2026 22:11
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