Skip to content

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

Closed
csillag wants to merge 2 commits into
anomalyco:devfrom
csillag:csillag/make-web-embeddable-in-iframes
Closed

feat: make opencode web embeddable in iframes at a subpath#23912
csillag wants to merge 2 commits into
anomalyco:devfrom
csillag:csillag/make-web-embeddable-in-iframes

Conversation

@csillag

@csillagcsillag commented Apr 23, 2026

Copy link
Copy Markdown

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 (zod@4.1.8 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
csillagforce-pushed the csillag/make-web-embeddable-in-iframes branch from 88acdbc to 77df993CompareApril 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
csillagforce-pushed the csillag/make-web-embeddable-in-iframes branch from 77df993 to e83eb50CompareApril 23, 2026 13:40
@csillag

Copy link
Copy Markdown
Author

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

@csillag
csillagforce-pushed the csillag/make-web-embeddable-in-iframes branch 4 times, most recently from 686a8dd to 6488100CompareApril 25, 2026 22:11
@csillag
csillagforce-pushed the csillag/make-web-embeddable-in-iframes branch 2 times, most recently from 92bc6a5 to d103e28CompareMay 10, 2026 00:58
opencodeand others added 2 commits May 11, 2026 02: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 `zod@4.1.8`) 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) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@csillag