Remote control for OpenCode in corporate environments.
OpenCode RC lets developers run OpenCode on their machines while accessing it from a browser through a centralized API server with OIDC authentication. A reverse WebSocket tunnel means dev machines don't need inbound network access — they connect outward to the gateway.
flowchart LR
subgraph Dev["Developer's Machine"]
CLI["opencode-rc (CLI)"]
OC["opencode serve"]
end
subgraph K8s["Kubernetes"]
WEB["API Server"]
GW["Gateway"]
REDIS["Redis"]
end
subgraph Browser
UI["Web UI"]
end
CLI -- starts --> OC
CLI -- "WebSocket tunnel (outbound)" --> GW
GW -- registers session --> REDIS
WEB -- looks up session --> REDIS
UI -- OIDC login --> WEB
UI -- "requests /s/{session}/*" --> WEB
WEB -- "proxy via tunnel" --> GW
GW -- "forwards to" --> OC
- Developer runs
opencode-rcCLI — it authenticates via OIDC, startsopencode serve, and opens a WebSocket tunnel to the gateway - The gateway registers the session in Redis and multiplexes browser traffic through the tunnel
- The API server authenticates browser users via OIDC, looks up sessions in Redis, and proxies requests through the gateway
- The web UI provides a session picker and wraps the OpenCode web interface
| Component | Path | Description |
|---|---|---|
| API Server | api/ (TypeScript) |
OIDC auth, session lookup, reverse proxy to gateway (serves no UI) |
| Gateway | gateway/ (Go) |
WebSocket tunnel server, session registration, request multiplexing |
| CLI | cli/ (Node) |
OIDC login via PKCE, starts opencode serve, maintains tunnel to gateway |
| Web UI | ui/ (SolidJS) |
Session picker, user bar, wraps OpenCode's web interface |
| UI Image | ui/web/ (nginx) |
Serves the built SPA on its own host, configured with the API URL at container start |
- Kubernetes cluster
- An OIDC provider (Keycloak, Dex, Azure AD, etc.)
- Two OIDC clients configured:
- API client (confidential): for browser login via the API server. Redirect URI:
https://<api-host>/auth/callback - CLI client (public): for developer CLI login via PKCE. Redirect URI:
http://127.0.0.1/callbackwithout a port; per RFC 8252 the IdP must accept any loopback port (the CLI listens on 43212–43215). For an IdP that matches ports exactly, registerhttp://127.0.0.1:43212/callbackthrough:43215
- API client (confidential): for browser login via the API server. Redirect URI:
helm install opencode-rc ./charts/opencode-rc \
--set oidcMock.enabled=false \
--set oidc.issuer=https://sso.corp.example.com \
--set existingSecret=opencode-rc-secrets \
--set expose.type=ingress \
--set expose.host=example.com \
--set expose.ingress.className=nginx \
--set expose.ingress.tlsSecretName=wildcard-example-com-tlsexpose.* exposes the API, the gateway and the UI on hosts derived from a prefix and expose.host. The prefix is expose.hostPrefix, else fullnameOverride, else the Helm release name:
| Host | Routes |
|---|---|
{prefix}.{host} (e.g. opencode-rc.example.com) |
/tunnel → gateway (CLI tunnel), everything else → API |
{prefix}-ui.{host} (e.g. opencode-rc-ui.example.com) |
web UI (when ui.enabled) |
{prefix}-oidc.{host} (e.g. opencode-rc-oidc.example.com) |
bundled oidc-mock (when oidcMock.enabled) |
The UI and the API are served from two different hosts. The browser loads the UI from the UI host and calls the API host directly. Public URLs are {expose.scheme}://<host> (https by default — TLS usually terminates at the ingress controller, load balancer or Istio gateway); set api.publicUrl / ui.publicUrl to override them. The OIDC redirect URI defaults to <api public URL>/auth/callback, e.g. https://opencode-rc.example.com/auth/callback. The CLI gateway URL is the API host, e.g. https://opencode-rc.example.com. Only /tunnel of the gateway is exposed; its /healthz is not reachable from outside the cluster.
The Ingress sets nginx.ingress.kubernetes.io/proxy-read-timeout and proxy-send-timeout to "3600" by default so ingress-nginx does not cut idle CLI tunnels, SSE streams and terminal WebSockets; values in expose.ingress.annotations override them. With other ingress controllers, configure the equivalent long timeouts through expose.ingress.annotations.
With Istio, point expose.virtualService.gateways at existing Istio Gateways that accept the hosts above (e.g. a *.example.com server). The chart creates VirtualServices (networking.istio.io/v1 by default, which needs Istio 1.22+; set expose.virtualService.apiVersion: networking.istio.io/v1beta1 for older Istio) and routes /tunnel to the gateway; Istio route timeouts are disabled by default, so long-lived tunnels and streams need no override:
helm install opencode-rc ./charts/opencode-rc \
--set oidcMock.enabled=false \
--set oidc.issuer=https://sso.corp.example.com \
--set existingSecret=opencode-rc-secrets \
--set expose.type=virtualService \
--set expose.host=example.com \
--set 'expose.virtualService.gateways={istio-system/public-gateway}'The existingSecret must contain:
| Key | Description |
|---|---|
cookie-secret |
64-char hex string (32 bytes), HMAC key for access tokens |
oidc-client-secret |
OIDC client secret for the web client |
redis-url |
Redis connection URL (only if redis.enabled=false) |
| Value | Default | Description |
|---|---|---|
oidcMock.enabled |
true |
Deploy the bundled OIDC mock; empty oidc.* values default to it |
oidcMock.clients |
web opencode-rc, cli opencode-rc-cli, app opencode-rc-app |
Mock clients (id, secret, redirectUris); web redirect defaults to <api public URL>/auth/callback |
oidcMock.users |
alice, bob | Mock users |
oidc.issuer |
"" |
OIDC provider URL; required unless oidcMock.enabled |
oidc.clientId |
"" |
Web client ID (confidential); empty: the mock's web client, else opencode-rc |
oidc.cliClientId |
"" |
CLI client ID (public, PKCE); empty: the mock's CLI client, else opencode-rc-cli |
oidc.appClientId |
"" |
Mobile app client ID (public, PKCE, redirect URI com.opencode.rc:/auth/callback); empty: the mock's app client, else app sign-in is off |
oidc.userClaim |
sub |
ID token claim that identifies a user; web and CLI logins must get the same value (use oid for Entra ID) |
oidc.redirectUri |
auto-derived | OAuth callback URL; defaults to <api public URL>/auth/callback |
auth.accessTokenTTL |
15m |
Access token lifetime |
auth.sessionTTL |
7d |
Absolute login lifetime; refresh never extends it |
auth.allowedOrigins |
[] |
Extra browser origins allowed for return_to and CORS |
auth.appLoginPrompt |
select_account |
OIDC prompt sent to the IdP for mobile app logins (select_account, login, consent, or ""); check that your IdP honors it |
authz.webhook.url |
"" |
Access webhook (see Access Control); empty: every signed-in user may use their sessions |
authz.webhook.tokenSecret |
name "", key token |
Secret key sent to the webhook as Authorization: Bearer <token> |
authz.timeout |
5s |
Webhook timeout; a timeout denies |
authz.cacheTTL.allowed / .denied |
60s / 10s |
How long a decision is reused; open event streams and terminals are also re-checked every allowed |
admin.tokenSecret |
name "", key token |
Secret key holding the admin API bearer token; no admin API when the name is empty |
redis.enabled |
true |
Deploy Redis; set false to use external Redis via secret |
tlsInsecureSkipVerify |
false |
Skip TLS certificate verification for OIDC discovery |
expose.type |
none |
none, ingress (one Ingress) or virtualService (Istio VirtualServices) |
expose.host |
"" |
Base domain; hosts are {prefix}.{host}, {prefix}-ui.{host} and, with oidcMock.enabled, {prefix}-oidc.{host}. Required unless expose.type=none |
expose.hostPrefix |
"" |
Host prefix; empty: fullnameOverride, else the release name |
expose.scheme |
https |
Scheme browsers and the CLI use to reach the hosts |
expose.ingress.className |
"" |
ingressClassName of the Ingress |
expose.ingress.annotations |
{} |
Ingress annotations, merged over the default ingress-nginx proxy-read-timeout/proxy-send-timeout of "3600" |
expose.ingress.tlsSecretName |
"" |
TLS secret covering all hosts (e.g. a wildcard certificate); no tls section when empty |
expose.virtualService.gateways |
[] |
Existing Istio Gateways (namespace/name); required for virtualService |
expose.virtualService.apiVersion |
networking.istio.io/v1 |
VirtualService API version; networking.istio.io/v1beta1 for Istio older than 1.22 |
expose.virtualService.annotations |
{} |
VirtualService annotations |
api.cookieSecure |
null |
Secure flag of the login cookies; null: true iff the API public URL is https |
api.publicUrl |
"" |
Browser-facing API URL; overrides the URL derived from expose.*. Required when ui.publicUrl is set with expose.type=none |
ui.enabled |
true |
Deploy the UI image (nginx serving the built SPA) |
ui.publicUrl |
"" |
Browser-facing UI URL; overrides the URL derived from expose.*. Added to the API's allowed origins |
global.imageRegistry |
"" |
Override image registry for all components (e.g. registry.corp.example.com) |
See charts/opencode-rc/values.yaml for all options.
By default the chart deploys an OIDC mock with test users (alice/bob) and a built-in Redis, so it runs without external dependencies:
helm install opencode-rc ./charts/opencode-rcThe mock is configured by oidcMock.* only. Empty oidc.* values default to it: the issuer, the client ids and the web client secret. With expose.type set, it is exposed on {prefix}-oidc.{host} with that host as its issuer; set oidcMock.issuer when browsers reach it at another URL. The API and the gateway always talk to it in-cluster.
For a real IdP, set oidcMock.enabled=false, oidc.issuer and existingSecret. Without existingSecret the chart renders a Secret from secrets.*, whose defaults are for development only.
The iOS and Android apps sign in as a public OIDC client with PKCE, in the system browser view (Safari view / Custom Tab), which loads only the IdP. Set oidc.appClientId (API env OIDC_APP_CLIENT_ID); empty turns app sign-in off. Register that client at your IdP as:
- a public client (no secret), with PKCE (S256) required
- redirect URI
com.opencode.rc:/auth/callback - scopes
openid email profile
The API serves the client settings at GET /auth/app-config and exchanges the code at POST /auth/app-token. auth.appLoginPrompt (APP_LOGIN_PROMPT) applies to app sign-ins through /auth/app-config. On corporate iOS devices with a per-app VPN, the IdP host must be reachable from Safari (in the VPN's Safari Domains); the API host need not be.
Everyone who signs in can connect their machine with the CLI. Whether they may list and open sessions in the UI can be decided by a webhook you run. With authz.webhook.url set, the API sends:
POST <authz.webhook.url>
Content-Type: application/json
Authorization: Bearer <token> (only with authz.webhook.tokenSecret)
{"user": {"uid": "<oidc.userClaim value>", "email": "[email protected]", "name": "Alice"}}and expects 200 with {"allowed": true} or {"allowed": false, "reason": "..."}. Any other response, an error or a timeout denies, and is not cached. A denied user sees a "No access" screen with the reason (plain text, up to 300 characters); the session list and proxy return 403 with {"error": "forbidden", "reason": "..."}.
Decisions are cached in Redis per user for authz.cacheTTL.allowed / .denied. To apply a change sooner, set admin.tokenSecret and call the admin API on the API host:
curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" https://<api host>/admin/authz/cache/<uid> # one user
curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" https://<api host>/admin/authz/cache # everyoneOpen event streams and terminals are checked again every authz.cacheTTL.allowed and closed once the user is denied (a terminal closes with WebSocket code 1008 and the reason), and the web UI and apps switch to the No access screen. Set it to how fresh the webhook's answer is: a revocation reaches open streams within that time, and evicting the cache does not shorten it. If the check itself cannot run (Redis unavailable), streams stay open and are checked again at the next interval.
Open streams also close when the sign-in session they were opened with ends (signed out, or auth.sessionTTL reached), checked at the same interval, with or without a webhook.
One config.json per deployment presets the UI's server and switches features off:
{
"serverUrl": "https://api.rc.example.com",
"features": { "uploads": { "enabled": false } }
}Every key is optional and unknown keys are errors. Features (all enabled by default):
-
features.uploads.enabled: falseremoves image and file attachments from the UI (pasted, dropped or picked files and project icons) and makes the API reject them with403.@references to workspace files keep working (unlessdownloadsis disabled); pasting into the terminal is not covered. -
features.downloads.enabled: falsestops direct file reads through opencode-rc (the file viewer, file search, and prompts that reference files): the UI loses the file viewer, opening files (mod+p,/open), file results in the command palette, the diff's "view file", files and directories after@, and dragging files from the file tree into the prompt. The API rejects with403reading a file, text search (/find), and prompts that reference files: file attachments by path,@pathin a command's arguments (also arguments ending in@) and in a subtask's prompt (opencode reads those files into the session). An@agentin a command's arguments is rejected too: the API cannot tell it from a path. It also rejects editing stored message parts, and config changes that load files (instructions, or any{file:…}value). Chat, diffs, comments on diffs (sent without the file),@agents and MCP resources, file listing and name search keep working. This does not keep file contents on the machine: the AI can still read any file with its tools, and what it reads shows in the session and in its replies; diffs include the full contents of every changed file.shellandterminalrun commands on the machine, so they can read any file too: disable them as well to stop users from getting file contents through opencode-rc. Keeping code on the machine fully needs opencode's own permission config there (which files its tools may read); the browser cannot override it whiledownloadsorshellis disabled (below). -
features.terminal.enabled: falseremoves the terminal panel (a shell on the developer's machine) from the UI and makes the API reject terminal requests, including the WebSocket, with403. -
features.shell.enabled: falseremoves shell mode (!: one command run on the developer's machine, kept in the session) from the UI and makes the API reject it with403. The API also rejects what opencode would run on the machine: command arguments with shell interpolation (!`…`, or ending in!), a start command for a new worktree or a project, adding an MCP server, and config changes that set custom commands, MCP servers, the shell, plugins, formatters or LSP servers. The UI hides the terminal shell setting and the project's start command. The terminal and shell mode are separate: to stop users from running commands themselves, disable both. The AI's own tools are not affected. -
features.export.enabled: falseremoves exporting a session as JSON from the UI. The export is built in the browser from what the UI already shows, so there is nothing for the API to reject. -
features.share.enabled: falsemakes the API reject publishing a session through opencode's share service with403(unsharing still works). The UI has no sharing (opencode 1.19.1 discontinued it). It also rejects config changes that share every new session or choose the share server (share,autoshare,enterprise), even whileconfigis enabled. A dev machine whose opencode config has"share": "auto"still shares on its own; set"share": "disabled"there too. -
features.config.enabled: false(1.x only; 2.x never allows these) stops the browser from changing opencode's config. The API rejects with403updating the project or global config, adding an MCP server, setting a project's commands, and signing in with awellknowncredential (it makes opencode load a remote config). The UI hides the custom provider entry, disconnecting custom providers defined in the config, the terminal shell setting and the project's start command. Reading the config, signing in to a provider with an API key or OAuth, and choosing a model keep working. Config can run code on the machine (provider.*.npminstalls and imports a package) and expose its environment ({env:…}values, returned byGET /config): disableconfigtogether withshellanddownloads. -
features.mcp.enabled: falsestops the browser from connecting, disconnecting or signing in to the MCP servers in the machine's opencode config (connecting starts the server's command or reaches its remote service). The API rejects these with403; the UI lists MCP servers and their status without switches. MCP servers enabled in the config still load, and their resources stay available. Adding an MCP server is covered byconfigandshell.
The API always rejects some opencode 1.x routes the UI never uses, whatever the toggles: applying
a patch to the project (/vcs/apply), upgrading opencode (/global/upgrade), the terminal UI
control routes (/tui/*), /log, workspace sync and changes, switching the opencode console
account, and moving sessions between workspaces. It also removes provider keys, options and
headers (where API keys and auth headers live) from opencode 1.x provider and config responses
(including the response to a global config update), and from the config also MCP servers'
environment, headers and OAuth client secret, agents' options, and LSP and formatter
environments.
While downloads or shell is disabled, the API also rejects requests that set permission rules
(a session's rules, a prompt's tools, and the config's permission, agent, mode and
tools), so the machine owner's opencode permission config holds; neither UI sends them. A custom
command template that itself builds !`…` or @… around its placeholders (such as !$1,
@$1, or a placeholder inside its own !`…` ) still runs commands or reads files from plain
arguments: review the custom commands on those machines.
The API rejects (400) write requests whose path contains characters other than letters, digits
and / . _ ~ -, so a custom OPENCODE_RC_SESSION_ID must stick to those characters (the default
<hostname>-<id> does). Request bodies the API inspects are limited to 32 MiB.
- UI: the published
uiimage is generic and asks users for the server. Build your own withdocker build -f ui/web/Dockerfile --build-arg RC_CONFIG=path/to/config.json .and setui.image; for mobile apps seedocs/ios-app.md.RC_CONFIGmust be a path inside the build context that.dockerignoredoes not exclude (it excludesdocs/anddist/), and the build needs BuildKit (the default in current Docker). - API: set the chart value
configto the same content (the API ignoresserverUrl).
The UI is built twice: opencode 1.x at / and 2.x at /v2/. 2.x has its own settings under opencodeV2, independent of features:
{
"opencodeV2": {
"features": { "terminal": { "enabled": false } },
"versions": { "min": "2.0.26", "max": "2.0.27" }
}
}opencodeV2.featureshasuploads,downloads,terminal,shell,exportandmcp, with the same meaning as above. They are enabled unless set tofalse, so a deployment that disables features for 1.x must disable them here too.shareandconfigare not 2.x toggles (2.x refuses config changes always).- On 2.x,
downloadsalso stops chat links to file paths and images loaded from disk. The API rejects with400reading a file and prompts or commands with any attachment that is not inline (data:), so withuploadsalso disabled no attachment gets through.@references in text are not expanded by 2.x, so the@agentlimitation above is 1.x only.mod+pand/open(the "Open file" command) still open the command palette, which lists only commands and sessions. The directory picker still works. - On 2.x,
shellrejects a command's text by the same rule as 1.x arguments. Whiledownloadsorshellis disabled, the API rejects (400) creating or updating a session withpermissions. opencodeV2.versions(mininclusive,maxexclusive) can only narrow the supported range inconfig/opencode-versions.json; other 2.x versions are refused.- On 2.x,
mcpalso rejects signing in to an MCP server through its integration (mcp_…), and the UI leaves those integrations out of the Connect provider list; signing in to providers keeps working. - On 2.x the API only forwards the routes in an allowlist and always blocks some, such as
writes to opencode's config, pairing, adding or removing MCP servers, plugin updates,
persistent terminals, listing stored credentials (it returns their secrets),
git init,session/:id/generateand the debug location list; creating a worktree in a chosen directory is rejected too (seeapi/src/v2-routes.ts).
The API picks the 2.x or 1.x rules from the opencode version the dev machine's CLI reports, which it
cannot verify. Routes both versions serve (such as the terminal under /api/pty) follow the reported
version's toggle, so a feature disabled for only one version can be reached by a user who modifies
their CLI. Disable a feature for both versions to enforce it fully.
A session can open any folder on the machine (1.x ?directory=; 2.x session location, moving a
session, the directory picker). The agent then treats that folder as its project, so opencode's
"ask before touching files outside the project" rule applies relative to it, and the folder's file
names and git diffs go through opencode-rc. This is not a toggle.
opencode pair --remote (opencode 2.x) lets any user expose their own machine through opencode's
public relay (opentunnel.xyz), outside opencode-rc's sign-in and controls; only opencode's own password
protects it. If that matters, block it with network or endpoint policy.
opencode 1.x and 2.x on the same machine share opencode's data directory and database. 2.x migrates the database in place the first time it runs, and opencode does not guarantee that a machine switched back to 1.x afterwards can still use it.
Known 2.x limitations: files whose names contain #, %, ? or ; cannot be opened remotely,
and the 2.x UI includes opencode's Office preview, which makes the build (notably the mobile
apps) larger.
Override the image registry to pull from an internal registry:
helm install opencode-rc ./charts/opencode-rc \
--set global.imageRegistry=registry.corp.example.com/opencode-rcImages to mirror:
| Image | Tag |
|---|---|
ghcr.io/rophy/opencode-rc/api |
0.11.0 |
ghcr.io/rophy/opencode-rc/ui |
0.10.0 |
ghcr.io/rophy/opencode-rc/gateway |
0.8.0 |
ghcr.io/rophy/oidc-mock |
20260913-34fdbaf |
redis |
7.4.11-alpine |
Install:
npm install -g opencode-rcConfigure ~/.config/opencode/rc.json:
{
"gatewayUrl": "https://opencode-rc.example.com"
}The CLI gets the OIDC issuer and CLI client ID (oidc.cliClientId) from the server.
Run:
opencode-rcThe CLI authenticates via OIDC (opens browser), starts opencode serve, and maintains a tunnel to the gateway. Press Ctrl+C to disconnect.
Environment variables override the config file — see the CLI README for details.
Supported opencode versions: 1.x, and 2.x within the range in config/opencode-versions.json
(currently 2.0.26 up to, not including, 2.0.27). A machine running 2.x requires an opencode-rc CLI
release with opencode 2.x support, and a server that supports opencode 2.x.
| Variable | Description |
|---|---|
OPENCODE_RC_GATEWAY_URL |
Gateway URL |
TLS_INSECURE_SKIP_VERIFY |
Set to true to skip TLS certificate verification |