Skip to content

About

Minimalistic remote control for OpenCode with OIDC authentication

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

OpenCode RC

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.

Architecture

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
Loading
  1. Developer runs opencode-rc CLI — it authenticates via OIDC, starts opencode serve, and opens a WebSocket tunnel to the gateway
  2. The gateway registers the session in Redis and multiplexes browser traffic through the tunnel
  3. The API server authenticates browser users via OIDC, looks up sessions in Redis, and proxies requests through the gateway
  4. The web UI provides a session picker and wraps the OpenCode web interface

Components

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

Deploy with Helm

Prerequisites

  • 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/callback without 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, register http://127.0.0.1:43212/callback through :43215

Install

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-tls

expose.* 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)

Key Values

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.

Bundled OIDC Mock

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-rc

The 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.

Mobile app sign-in

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.

Access Control

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         # everyone

Open 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.

Deployment Config

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: false removes image and file attachments from the UI (pasted, dropped or picked files and project icons) and makes the API reject them with 403. @ references to workspace files keep working (unless downloads is disabled); pasting into the terminal is not covered.

  • features.downloads.enabled: false stops 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 with 403 reading a file, text search (/find), and prompts that reference files: file attachments by path, @path in a command's arguments (also arguments ending in @) and in a subtask's prompt (opencode reads those files into the session). An @agent in 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.

    shell and terminal run 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 while downloads or shell is disabled (below).

  • features.terminal.enabled: false removes the terminal panel (a shell on the developer's machine) from the UI and makes the API reject terminal requests, including the WebSocket, with 403.

  • features.shell.enabled: false removes shell mode (!: one command run on the developer's machine, kept in the session) from the UI and makes the API reject it with 403. 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: false removes 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: false makes the API reject publishing a session through opencode's share service with 403 (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 while config is 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 with 403 updating the project or global config, adding an MCP server, setting a project's commands, and signing in with a wellknown credential (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.*.npm installs and imports a package) and expose its environment ({env:…} values, returned by GET /config): disable config together with shell and downloads.

  • features.mcp.enabled: false stops 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 with 403; 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 by config and shell.

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 ui image is generic and asks users for the server. Build your own with docker build -f ui/web/Dockerfile --build-arg RC_CONFIG=path/to/config.json . and set ui.image; for mobile apps see docs/ios-app.md. RC_CONFIG must be a path inside the build context that .dockerignore does not exclude (it excludes docs/ and dist/), and the build needs BuildKit (the default in current Docker).
  • API: set the chart value config to the same content (the API ignores serverUrl).

opencode 2.x

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.features has uploads, downloads, terminal, shell, export and mcp, with the same meaning as above. They are enabled unless set to false, so a deployment that disables features for 1.x must disable them here too. share and config are not 2.x toggles (2.x refuses config changes always).
  • On 2.x, downloads also stops chat links to file paths and images loaded from disk. The API rejects with 400 reading a file and prompts or commands with any attachment that is not inline (data:), so with uploads also disabled no attachment gets through. @ references in text are not expanded by 2.x, so the @agent limitation above is 1.x only. mod+p and /open (the "Open file" command) still open the command palette, which lists only commands and sessions. The directory picker still works.
  • On 2.x, shell rejects a command's text by the same rule as 1.x arguments. While downloads or shell is disabled, the API rejects (400) creating or updating a session with permissions.
  • opencodeV2.versions (min inclusive, max exclusive) can only narrow the supported range in config/opencode-versions.json; other 2.x versions are refused.
  • On 2.x, mcp also 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/generate and the debug location list; creating a worktree in a chosen directory is rejected too (see api/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.

Air-Gapped Environments

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-rc

Images 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

CLI Usage

Install:

npm install -g opencode-rc

Configure ~/.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-rc

The 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.

CLI Environment Variables

Variable Description
OPENCODE_RC_GATEWAY_URL Gateway URL
TLS_INSECURE_SKIP_VERIFY Set to true to skip TLS certificate verification

About

Minimalistic remote control for OpenCode with OIDC authentication

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages