2026-08-03 18:17:31 +00:00
2026-08-03 18:17:31 +00:00
2026-08-03 18:17:31 +00:00
2026-08-03 18:17:31 +00:00
2026-08-03 18:17:31 +00:00

@modelprofile.com/opencode-controller

Run OpenCode behind a passkey-authenticated controller with a detached CLI lifecycle and a foss.global browser UI.

In local mode the controller binds its UI and @api.global/typedsocket RPC listener to 127.0.0.1 only — plain-HTTP admission rests on browser-supplied headers, so the loopback bind is the network boundary. Only proxy mode (--behind-tls-proxy) binds all interfaces, for a trusted TLS reverse proxy. The supervised OpenCode server always remains on 127.0.0.1, uses a generated per-process Basic Auth password, and is accessed only through the official @opencode-ai/sdk v2 client. OpenCode's own browser application is not embedded, proxied, scraped, or duplicated.

Security posture

The controller runs on @api.global/typedserver 9 and @api.global/typedsocket 6, whose client connection tags are default-deny and owner-controlled. The controller registers no clientTagPolicy, so every client-initiated tag mutation is rejected at the transport layer; connection identity and authentication state live exclusively in server-owned peer state, and event pushes target connections by verifying that state server-side — never by client-managed tags. (Earlier revisions of this repository were deliberately withheld from publication until these upstream packages shipped, rather than concealing the boundary with a local shim.)

Setup-code attempts are globally rate limited (10 per minute, wrong codes included). During the pre-enrollment window a client that can reach the listener could therefore hold enrollment closed by burning the budget — a deliberate lockout-over-guessing tradeoff: the window exists only before the first enrollment, and in local mode only loopback processes can reach it at all.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Architecture

Browser
  └─ exact configured HTTP(S) origin
      └─ controller UI + TypedSocket RPC
         (127.0.0.1:<controller-port> local mode; 0.0.0.0 behind the TLS proxy)
          ├─ WebAuthn passkey verification
          ├─ SmartData configuration, credentials, ceremonies, and audit events
          └─ official OpenCode SDK v2
              └─ authenticated OpenCode server (127.0.0.1:<opencode-port>)

The browser receives only controller DTOs. It cannot select a filesystem path or model, and it never receives the OpenCode Basic Auth password. The configured workspace is resolved to one canonical directory and becomes immutable when the controller is initialized.

Requirements

  • Node.js 24 or newer
  • MongoDB reachable by the controller
  • A WebAuthn-capable browser
  • Linux or macOS for detached start and CLI-managed stop

The package pins @opencode-ai/sdk and the official OS/architecture-specific OpenCode binary packages to the same tested version. It resolves the lockfile-installed platform executable directly, without a wrapper postinstall or unrelated global binary.

The OpenCode child receives only ordinary runtime path, user, locale, temporary-directory, XDG, proxy, and CA environment variables by default. Controller/database variables and unrelated parent secrets are not inherited. Explicitly delegate provider credentials when needed:

export OPENCODE_CONTROLLER_FORWARD_ENV='ANTHROPIC_API_KEY,OPENAI_API_KEY'

The comma-separated names must already exist in the controller environment. Controller variables, the delegation directive itself, and generated OpenCode server credential names can never be forwarded.

Credential-free HTTP_PROXY and HTTPS_PROXY URLs are part of the minimal runtime environment. Proxy URLs containing user information, and malformed proxy values, require explicit delegation through OPENCODE_CONTROLLER_FORWARD_ENV.

Install and build

pnpm install
pnpm build
node cli.js help

The package is currently released through git (code.foss.global) only; npm publication is intentionally withheld for now. Install from the repository:

git clone https://code.foss.global/modelprofile.com/opencode-controller.git
cd opencode-controller && pnpm install && pnpm build
node cli.js help

If a registry release is published later, the binary name is opencode-controller (pnpm add --global @modelprofile.com/opencode-controller).

Database configuration

Structured application state is stored exclusively through SmartData. There is no PID, configuration, authentication, or JSON persistence fallback on the filesystem.

export OPENCODE_CONTROLLER_MONGO_URL='mongodb://user:password@127.0.0.1:27017'
export OPENCODE_CONTROLLER_MONGO_DB='opencode_controller'

OPENCODE_CONTROLLER_MONGO_URL defaults to mongodb://127.0.0.1:27017. OPENCODE_CONTROLLER_MONGO_DB defaults to opencode_controller.

For local package development, MongoDB is declared as the GitZone service:

gitzone services start mongo
gitzone services status

Use the MONGODB_URL from .nogit/env.json as OPENCODE_CONTROLLER_MONGO_URL. GitZone service data remains under .nogit/ and is not application persistence or package content.

First start

From the workspace OpenCode should control:

opencode-controller start

The detached process first reserves the controller listener (127.0.0.1:4097 in local mode; 0.0.0.0:4097 only with --behind-tls-proxy), then starts the official OpenCode server in headless server mode on 127.0.0.1:4098. The terminal reports readiness only after both are available and, before the first enrollment only, prints a one-time setup code — a random 32-byte value by default, or the operator's own value when --setup-code is passed.

Open the printed URL, enter the setup code, and create a passkey. The code expires after 30 minutes and is atomically consumed by the first successful credential enrollment. If an unenrolled code expires, stop and start the controller to rotate it.

--setup-code <code> accepts 4256 printable non-whitespace ASCII characters and trades entropy for convenience — understand what it weakens before using it. The enrollment rate limiter (10 attempts per minute) is in-memory and resets on every restart, so a fixed code accumulates guessing budget across restarts instead of being invalidated by rotation the way random codes are. The stored verifier is an unsalted SHA-256 hash, so a low-entropy code is trivially recoverable by anything that can read the controller database. And while the code never appears in the detached child's process list (it travels over parent-child IPC), it is visible in the parent CLI's own argv and your shell history. On a shared machine, prefer a long random value or omit the option entirely.

Exactly one credential exists per controller database, permanently: enrollment is first-wins and no re-enrollment path is exposed. A lost passkey is not recoverable through the controller — recovery means deleting the controller's database (or its auth collections) and enrolling again from a fresh setup code. This is deliberate; an online credential-reset path would be a second, weaker way in.

Later browser connections must perform a fresh passkey authentication. Authentication is bound to that exact TypedSocket peer and disappears when the socket disconnects. The controller does not issue bearer tokens, cookies, trusted client tags, or localhost authentication bypasses; the only non-passkey credential is the CLI-minted temporary password described under "CLI temporary password".

CLI

opencode-controller start [options]
opencode-controller status [--port 4097] [--json]
opencode-controller stop [--port 4097]
opencode-controller foreground [options]
opencode-controller temp-password [--port 4097] [--ttl-hours 24]
opencode-controller help
opencode-controller --version

First-start options:

--port <port>              controller port, default 4097
--opencode-port <port>     loopback OpenCode port, default 4098
--directory <path>         fixed OpenCode workspace, default current directory
--projects-root <path>     default base directory: relative project paths and
                           initial suggestions resolve here; adopted once on
                           first v2 start, immutable afterward
--public-origin <origin>   exact browser WebAuthn origin
--rp-id <hostname>         WebAuthn RP ID
--behind-tls-proxy         declare trusted TLS termination upstream
--setup-code <code>        operator-chosen setup code (see the warning above)

Runtime configuration is durable and immutable per controller port. Later management commands must use the same --port; omitted first-start options are loaded from SmartData.

The detached CLI does not write a PID file. It appends the controller's stderr diagnostics to $XDG_STATE_HOME/opencode-controller/controller-<port>.log (default ~/.local/state/opencode-controller/; rotated to .old past 1 MiB on start; stdout stays discarded because the transport logs every connection there). The log file must be a regular file owned by the current user without group or world access — the start fails loudly rather than logging somewhere surprising. The initial setup code travels once over parent-child IPC. status connects only to 127.0.0.1, validates package and protocol identity, and stop additionally validates the exact CLI path (relative launches are resolved against the process's working directory), command, effective port (an argv without --port verifies as the default port), process group, and process-start fingerprint before signaling an owned PID or process group. It never kills processes by name.

foreground is available for attached operation and diagnostics. Detached management is intentionally limited to Linux and macOS in this release.

CLI temporary password

temp-password mints a random 24-byte password that signs the browser UI in without a passkey — the "Use a CLI temporary password" link on the login screen opens the matching input. It exists for temporary and scripted access (for example, letting an agent drive the real UI for debugging) without enrolling a credential.

The command talks to the controller database directly, so it requires the same OPENCODE_CONTROLLER_MONGO_URL / OPENCODE_CONTROLLER_MONGO_DB environment as the running controller — minting authority is database access, and nothing network-reachable can issue one. Only the SHA-256 hash is stored. The lifetime is capped at 24 hours (--ttl-hours, default 24), MongoDB reaps expired entries via TTL index, at most 8 can be active at once, and the password is reusable until expiry. Logins ride the same per-connection authentication, origin checks, unauthenticated-rate limits, and audit trail (temppassword.create / temppassword.login) as passkey ceremonies.

Projects and default model

Chats are organized into projects. A project is any registered existing directory on disk: the browser's add-project field accepts full absolute paths (/any/path/on/disk) as well as paths relative to the configured --projects-root default base (org/repo), and suggests matching folders live while typing either form — absolute queries browse the actual filesystem. Hidden directories are never suggested (though an explicitly typed absolute path may contain dot segments), dot navigation is rejected, and registration fails visibly when the path does not exist — the controller never creates project directories. Projects inside the default base get short relative display names; projects elsewhere are named by their absolute path. Every session method is scoped to one project, and one supervised OpenCode server serves all of them through per-call directory scoping. On the first v2 start the fixed workspace directory is registered as the initial project automatically. Removing a project only deregisters it — files on disk are never deleted. Note the trust consequence: an authenticated browser session can register and operate on any directory the controller's user can read.

The default model is set in the settings dialog and persisted server-side: the picker lists the providers and models OpenCode actually has configured, and the stored default applies to every prompt in all projects and from all browsers. The model picker in the chat composer overrides the model for that chat only, in that browser tab, without touching the default. Reasoning effort follows the same pattern: models that advertise effort variants (for example low/medium/high/xhigh/max) get an effort dropdown next to the model picker and in the settings dialog — the persisted default effort applies everywhere, a composer change applies to that chat only, and default sends no explicit effort. Models without variants show no effort control. Project and settings changes are pushed live over the authenticated TypedSocket connection to every connected client.

Typing / in the composer opens slash-command autocomplete fed by OpenCode's per-project command catalog (built-ins plus custom commands). A sent /command args expands the command's template server-side — honoring an agent or model pinned by the command — and runs asynchronously like any prompt; unknown /text is sent literally.

The UI shows the running controller version in the header and reloads itself automatically when a reconnect reports a different backend version, so an upgraded controller never serves a stale browser bundle.

Remote browser access

WebAuthn requires a secure context away from localhost. Plain HTTP is therefore accepted only for the exact development origin http://localhost:<controller-port>.

TLS reverse proxy

export OPENCODE_CONTROLLER_TRUSTED_PROXY_SECRET="$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))")"
opencode-controller start \
  --public-origin https://controller.example.com \
  --rp-id controller.example.com \
  --behind-tls-proxy

Remote mode deliberately requires a trusted TLS reverse proxy so loopback CLI management remains available over the controller's HTTP listener. The proxy must remove client-supplied copies of the transport headers, inject the configured secret as X-OpenCode-Controller-Proxy-Secret, and set X-Forwarded-Proto: https on WebSocket upgrades; browsers never receive the secret. The controller rejects browser WebSockets without both values. Preserve the exact public Host and browser Origin, forward WebSocket upgrades, apply source-address limits to upgrades and unauthenticated ceremonies, and restrict direct access to the controller backend to the trusted proxy network. Do not expose an unprotected plain-HTTP backend route.

Backend isolation is a hard requirement in proxy mode, not defence in depth. The backend listener binds all interfaces there, and origin-less WebSocket connections (the loopback CLI management path) are admitted without the proxy secret by design — the controller cannot see the peer's network address to restrict this itself. Every pre-authentication surface on a directly reachable backend port is exactly as exposed as that port.

The proxy secret authenticates only the TLS termination boundary. It is not a user credential and never replaces the required per-connection passkey ceremony.

RP IDs must equal the public-origin hostname or a valid parent domain. Public suffixes, private multi-tenant suffixes, IP addresses, and unrelated domains are rejected.

Session and permission control

After passkey authentication, the UI can:

  • list, create, select, and delete sessions in the fixed workspace;
  • read normalized messages, reasoning, tools, usage, and todos;
  • send text prompts and abort a busy session;
  • display OpenCode permission requests as first-class UI state;
  • explicitly allow a permission once or reject it;
  • answer OpenCode questions with the advertised options (single- or multi-select) or a free-text answer where the question allows one.

Permission requests and questions render inline in the conversation, interleaved with messages like any other turn; answered cards stay in the transcript showing what was decided. Oversized transcripts never fail to load: content past the transfer budget shows an elision marker and, when even that is not enough, the oldest messages drop behind an "earlier messages elided" marker — the newest context always loads.

A new chat starts as a client-side draft: the chat pane shows the project directory the session will start in, and nothing is persisted until the first message is sent — sending creates the OpenCode session and delivers the message in one step, and an abandoned draft leaves no empty session behind. Right-clicking a conversation opens a context menu with Rename (updates the OpenCode session title), Archive, and Delete (with confirmation). Archived chats are hidden from the conversation list by default and live in a dedicated archived dialog reachable from the sidebar. Only top-level conversations appear in the list — subagent child sessions stay internal to their parent chat.

Conversations can be organized into groups: the "New group" button below the list creates one, dragging a chat onto a group files it there (drag also reorders chats within a group), and right-clicking a group header offers rename and delete — deleting a group never touches its chats, they return to the ungrouped section. Groups and their ordering are stored per project in the controller database, so they survive reloads and stay in sync across every connected browser. Archiving or deleting a chat also removes it from its group.

The New button also offers a terminal: a real shell (the user's default shell) started by the supervised OpenCode server in the project directory and listed in the sidebar alongside chats with a terminal icon. The browser renders it with xterm.js; keystrokes and output travel over the same authenticated TypedSocket connection as everything else — no extra port and no CDN scripts. The PTY lives server-side: closing or reloading the browser never ends it, reattaching replays the scrollback, and it ends only when the shell exits, when it is explicitly terminated from the right-click menu (Rename is there too), or when the controller itself stops. When several browsers view one terminal, the smallest client's grid determines the PTY size. Terminal keystrokes are not written to the audit log — creating, renaming, attaching, and terminating terminals are.

By default the controller never auto-accepts a permission. The settings dialog offers a global auto-accept permissions (yolo) toggle, persisted server-side: while enabled, the controller itself replies once to every pending permission request in all registered projects (including requests that were already waiting when the toggle turned on), and each auto-reply is written to the audit log like a manual one. Each chat additionally has an Ask / Yolo switch in its composer: Yolo auto-accepts that conversation's permission requests for the lifetime of the controller run without touching the global default (the durable setting stays the global toggle). Questions are never auto-answered. Persistent broad always grants remain unexposed by the protocol — the inline card's "always" answer grants once and OpenCode simply asks again — and no background or sleeping browser is treated as an approval agent: auto-accept is a deliberate server-side policy switch, not a client behavior.

OpenCode global events are filtered to the configured directory and pushed to authenticated TypedSocket peers. The UI debounces those events into refreshes; it does not run a persistent polling loop.

Persistence and audit boundaries

SmartData owns:

  • the immutable runtime configuration;
  • setup authority hashes and generations;
  • passkey credentials and compare-and-swap counters;
  • single-use WebAuthn ceremony records with expiry;
  • bounded, correlated audit events for controller, setup completion, authentication, session, permission, and question actions, retained for 90 days. Audited operations first persist an attempted record before any state transition or OpenCode side effect, then append the terminal outcome.

Audit records may contain bounded peer, credential, session, or permission-request identifiers. They cannot contain setup codes, WebAuthn challenges or payloads, prompt text, OpenCode credentials, or arbitrary fields.

Development

pnpm build
pnpm run check:test
pnpm test

The default suite runs without a database service. To include the exact-persistence integration test against a disposable MongoDB test service, set OPENCODE_CONTROLLER_TEST_MONGO_URL before running pnpm test; the test creates a unique database name for that run.

The production build uses tsbuild tsfolders so server code, shared TypedRequest contracts, and browser sources are checked and emitted, then tsbundle produces the static browser bundle.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
2.5 MiB
Languages
TypeScript 99.9%