Remote Hub Deployment
An opencodex hub keeps provider credentials and usage state on one host while authenticated clients
use its data plane remotely. The browser-facing management plane is separate: an optional listener
binds only 127.0.0.1, serves the dashboard and /api/*, and is intended to sit behind Tailscale
Serve or another operator-owned HTTPS frontend.
The data plane is one port. Remote machines dial hostname:port with their own per-client key;
the hub’s own processes dial 127.0.0.1:<the same port> with no credential, through the loopback
companion listener. Start from the recipe below, then hand a
second machine a ready-made command with ocx hub invite.
The management ingress never serves /v1/*, /healthz, or /readyz. When explicitly enabled,
Remote Workspace admits only its paired bearer-authenticated agent WebSocket and one-time pairing
exchange; see Remote Workspace. Do not publish its
port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a
public-internet surface and is outside this deployment model.
Trust and consent boundaries
Section titled “Trust and consent boundaries”- Provider and OAuth credentials stay on the hub. Never copy them into a client, image layer, service definition, support bundle, screenshot, or command line.
- The data admission token is delivered through the owner-only
service-api-tokenfile orOCX_API_TOKEN_FILE. It is not a management credential. - A raw management admin token can perform ordinary administration, but it cannot mint a browser
session or authorize consent-bearing actions such as starring the repository. Those actions
require a server-issued
gui-session, matching browser origin, and CSRF token. Tailscale-User-Loginis trusted only on the separately bound management ingress. The same header on the public listener is ignored.remoteGui.allowedTailscaleUserscontrols session issuance; it does not create a new general-purpose principal.
Roles and direct data flow
Section titled “Roles and direct data flow”standalone keeps data and management on one machine. A hub owns provider credentials, the
catalog, and usage records. A client stores only its connection metadata and one per-client data
key. Codex and Claude traffic goes directly from the client to the hub data listener; it is not
tunneled through the dashboard or the loopback management relay.
Connect with exactly one transient authority source. The authority is read from stdin and is never written to config or the token file:
ocx connect https://hub-name.tailnet-name.ts.net --pairing-code-stdinocx connect statusocx syncYou do not have to assemble that line by hand. ocx hub invite, run on the hub, mints the code and
prints the exact command — including both origins — for the machine that is joining. See
Inviting another machine.
The hub automatically issues a per-client key. The client writes it to the existing owner-only
service-api-token file, never config.json. While connected, usage comes from the hub usage store
filtered to that client’s stable apiKeyId. After disconnect, usage comes from the local store.
OpenCodex does not mirror usage between the two stores.
Rotate a connected client with a fresh transient authority:
ocx connect rotate --pairing-code-stdin# or, only over HTTPS:ocx connect rotate --admin-token-stdinRotation keeps the old and new data keys valid for at most ten minutes under the same apiKeyId.
The client backs up the old token as service-api-token.prev, atomically installs and probes the new
key, then commits. If a commit response is uncertain, rerun the rotate command with transient
authority; recovery probes both files before committing or restoring. Never delete either file when
recovery reports that both candidates were rejected.
ocx disconnect is local and works while the hub is offline. It restores local client state and
does not revoke the hub key. After disconnect, revoke that key from Integrations → API Keys on
the hub. ocx connect revoke --admin-token-stdin is available only while still connected and uses
the persisted apiKeyId; it accepts no id override. Browser session logout/expiry is separate from
data-key rotation, revocation, and disconnect.
What a connected client shows
Section titled “What a connected client shows”A client stores no provider credentials and no catalog of its own, so its local config and
credential store are empty by design — and reading them as the truth produces a confident, wrong
answer about what the hub can serve. On a connected client ocx status therefore leads with
State from hub <origin> and sources the OAuth-logins, providers and delegable-models lines from
the hub over the data plane, tagging the lines that really describe this machine (local): the
proxy, the service, the Codex binary and shim, and the local ports. ocx status --json carries the
same answer as runtimeRole plus a remoteHub block whose stateSource is hub, cache, or
unavailable — never the client’s own state. An older hub that does not serve /v1/hub-state
reports unavailable with an instruction to upgrade the hub rather than silently falling back to
local login state, and ocx config show on a client prints a _remoteHub note saying the
credentials and model availability live on the hub. The hub read uses the per-client data key
only; no admin token and no provider secret ever reaches a client.
ocx status makes a live hub-state request only when the saved connection still matches the
status snapshot and the data-token file matches that connection. If either check fails, it skips
the request and shows matching cached hub state, or unavailable if no matching cache exists.
Linux systemd or macOS launchd
Section titled “Linux systemd or macOS launchd”Bind the data listener to the hub’s Tailscale address, enable the loopback companion so the hub’s own processes reach that same port without a credential, and publish management separately. The values below are examples:
ocx config set runtimeRole hubocx config set hostname 100.64.0.10ocx config set corsAllowOrigins '["http://localhost:10100"]'
# A fresh standalone config has no `hub` or `remoteGui` object, and `ocx config set` does not# create a missing parent: a nested set fails with `config parent path not found: hub`. Setting# `runtimeRole` does not create it either. Create each object first, then set its fields.ocx config set hub '{}'ocx config set remoteGui '{}'ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"'ocx config set hub.dataPublicOrigin '"https://hub-name.tailnet-name.ts.net:8443"'ocx config set hub.managementIngress '{"enabled":true,"port":10101}'ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]'
# One port. Remote machines dial 100.64.0.10:10100 with their own key; the hub's own local# processes dial 127.0.0.1:10100 with no credential, on that same port.ocx config set unauthenticatedLoopbackListener '{"enabled":true}'
# No token to export: install provisions the hub's own data-plane token. See below.ocx service installocx service statusocx status # the "Hub:" block summarizes every line aboveOn a genuinely empty configuration you can set each object in one call instead:
ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","dataPublicOrigin":"https://hub-name.tailnet-name.ts.net:8443","managementIngress":{"enabled":true,"port":10101}}'ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}'Use that form only when the object does not exist yet. A whole-object set replaces the object
rather than merging into it, so running the line above against a config that already had
hub.managementIngress silently drops the ingress. When you are adapting an existing
configuration, set one field at a time — the parent is already there, so the nested form works and
touches nothing else.
Two details that decide whether a line is accepted. The value is parsed as JSON first and falls
back to the raw string, which is why a URL is written as '"https://…"': objects, arrays, booleans
and numbers must be valid JSON. And hub and remoteGui are strict, so a mistyped key is rejected
at write time as schema_invalid: hub.<field> instead of becoming a setting that never takes
effect. managementPublicOrigin and dataPublicOrigin must each be a bare origin with no path,
query or fragment.
The data-plane token provisions itself
Section titled “The data-plane token provisions itself”There is no export OPENCODEX_API_AUTH_TOKEN=… step before ocx service install. On a non-loopback
bind the installer resolves the hub’s data-admission token by precedence and writes the result to
the owner-only service-api-token file, mode 0600:
OPENCODEX_API_AUTH_TOKEN, when the installing shell exports one. An operator who wants to own that value keeps owning it.- The existing
service-api-tokenfile. Reusing it is what makesocx service install,ocx service repairand a restart idempotent; regenerating would silently invalidate every per-client key already exchanged against the old value. A reused file is re-checked, not trusted — see the admin-token paragraph below. - 32 fresh random bytes, hex. This is the branch that removes the manual step.
The command prints the path, never the value. The launchd plist and the systemd user unit read
that protected file when the process starts; neither embeds the literal token. Do not paste the
value into ocx config show, unit/plist output, screenshots, or support bundles. A foreground
ocx start on the hub reads the same file, so it binds the non-loopback hostname without an
exported token either.
A management admin token is refused wherever it turns up, and the refusal names the remedy for
that place. In OPENCODEX_API_AUTH_TOKEN: unset OPENCODEX_API_AUTH_TOKEN and rerun. In the
reused service-api-token file — the shape of the original incident, and still reachable on a
machine where the admin token was once pasted there by hand — delete the file and run
ocx service repair, because unsetting a variable says nothing about a file. Both checks run
ahead of the loopback short-circuit, so a loopback install is checked too: the launch wrapper
reads that file into OPENCODEX_API_AUTH_TOKEN whatever the hostname, which is what fences the
management API closed at boot.
The two planes are different credentials — the data token admits /v1/* callers and administers
nothing. Since the service provisions its own token, there is no reason to export either one.
ocx service repair never demands the variable again once the file exists.
ocx status reports the token’s state without its value: present (file), unsafe (file) (it
exists but is not owner-only — fix the permissions), admin-collision (file) (the incident shape;
the block adds the consequence and the fix), or missing. The state is always about the file,
because the launch wrapper overwrites the environment from it before exec — a separate sub-line
reports OPENCODEX_API_AUTH_TOKEN being set in your shell, since that is what a foreground
ocx start in that shell would use.
One port, and the ported alternative
Section titled “One port, and the ported alternative”unauthenticatedLoopbackListener: {"enabled": true} with no port is the companion form: a
second socket on 127.0.0.1:<proxy port> — the same port number the public listener uses on the
tailnet address. That is the address every local integration already writes, so nothing on the hub
has to be taught a new port, and one port stays the whole remote data surface.
The companion form is accepted only when hostname is a specific non-loopback, non-wildcard
address. On 127.0.0.1, localhost, 0.0.0.0 or :: the public listener already holds that
loopback address, so opencodex refuses the pair at write time and again at startup — naming the
collision — rather than letting the second bind fail. On those binds you do not need the listener at
all: a loopback bind already admits local callers.
The older ported form still works and is the alternative when you want the two surfaces on separate ports:
ocx config set unauthenticatedLoopbackListener '{"enabled":true,"port":10104}'With a port set, the local integrations follow the listener and write http://127.0.0.1:10104
instead. The port must differ from the proxy port and is never OS-assigned: an ephemeral port would
change across restarts while already-running app-servers kept the previous base_url.
Restart the proxy after changing this field. The sockets are bound once at startup and the local
client files are written from the resolved value, so a running hub keeps its old answer. On a ported
hub that is the difference between ocx claude reaching the listener and getting a 404 from it. On
a background service the verb is ocx service restart, which always restarts — see
macOS service operations. ocx restart is a different verb: it bounces
the proxy process you started yourself, not the service the manager supervises.
The hub’s own local clients
Section titled “The hub’s own local clients”A hub used to be the one machine that could not use itself: ocx claude, Claude Desktop, Cursor,
the system-env injection and the routed vision helper all dial http://127.0.0.1:<port>, which
does not exist when the listener is bound to a tailnet address. With the loopback listener enabled
they work on the hub:
ocx sync # the hub now writes its own Codex/Grok blocksocx claude # Claude Code wired to the hub's own loopback addressThe listener carries inference wires only: POST /v1/responses and its WebSocket upgrade,
POST /v1/responses/compact, POST /v1/messages, POST /v1/chat/completions,
POST /v1/alpha/search, GET /v1/models, and the realtime voice surface.
POST /v1/messages/count_tokens is deliberately not admitted, so Claude Code falls back to
local token estimation — a cosmetic loss, not a broken launch. /api/*, /healthz, /readyz and
the dashboard all return 404 there: local management reads such as ocx claude’s discovery call go
to the authenticated management surface with a management credential, never to an unauthenticated
socket. That is why the management ingress and this listener remain two different things.
With the listener off, a hub deliberately does not rewrite its own client configs, and every skip names the gate that stopped it:
This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unlessunauthenticatedLoopbackListener is enabled.That sentence means the hub gate, not your clientIntegrations toggle. ocx ensure leaves an
existing managed Grok block in place when it is gated rather than stripping it, and
ocx restore back reports the gate instead of blaming a competing writer.
Acceptance on the data plane
Section titled “Acceptance on the data plane”Prove liveness and readiness on the public data listener:
curl --fail --silent http://100.64.0.10:10100/healthzcurl --fail --silent http://100.64.0.10:10100/readyzA 200 from /healthz proves only that the process is alive. Deployment acceptance also requires
/readyz, an authenticated GET /v1/catalog, and one real routed response.
Tailscale Serve
Section titled “Tailscale Serve”First prove the management socket is loopback-only, then publish it through Serve:
ss -ltnp | grep 10101 # Linux: expected 127.0.0.1:10101 onlylsof -nP -iTCP:10101 -sTCP:LISTEN # macOS: expected 127.0.0.1 only
tailscale serve --bg --https=443 http://127.0.0.1:10101tailscale serve statusSet hub.managementPublicOrigin to the exact HTTPS origin shown by Serve. Add the operator’s exact
Tailscale login to remoteGui.allowedTailscaleUsers; an empty list means no remote identity can mint
a session. Verify both directions:
# Negative: the loopback-only port must not be reachable through the node's tailnet address.curl --fail --connect-timeout 3 http://100.64.0.10:10101/ && echo "unexpected exposure"
# Positive: the HTTPS dashboard loads through Serve from an allowed tailnet user.curl --fail --silent --show-error https://hub-name.tailnet-name.ts.net/ >/dev/nullThe positive browser test must use a real signed-in Tailscale session; a bare curl may not carry the
identity headers needed for automatic session issuance. Pairing remains the fallback when the HTTPS
frontend cannot provide trustworthy Tailscale identity.
Giving the data listener TLS
Section titled “Giving the data listener TLS”The Serve mapping above publishes the management ingress only. That ingress never serves
/v1/*, /healthz or /readyz, so on its own it does not give a remote client a usable data
plane. opencodex also terminates no TLS of its own: the listener is plain HTTP and HTTPS is always
an operator-owned frontend.
Serve can be that frontend for the data plane too, on a second HTTPS port. On macOS it needs one
extra hop, because Tailscale Serve proxies only to 127.0.0.1 — it cannot target the listener you
bound to the node’s own tailnet address, and the App Store build of the macOS client refuses a
remote destination outright. Run a loopback forwarder on the hub and point Serve at that:
# Any loopback TCP forwarder works; socat is one. Pick a port the hub is not already using:# with the loopback companion enabled, 127.0.0.1:10100 belongs to opencodex itself.socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 &
tailscale serve --bg --https=8443 http://127.0.0.1:10110tailscale serve status # expect both mappings: 443 -> 10101, 8443 -> 10110Do not point Serve at the loopback companion listener instead. It is a real socket on
127.0.0.1:10100, so the mapping would be created and then fail the same way the trap below
describes: the companion runs the loopback admission policy, which requires a loopback Host
header, and Serve forwards Host: hub-name.tailnet-name.ts.net. The companion exists for processes
on the hub, which send a loopback Host of their own. The forwarder carries the tailnet-bound
listener, whose credential admission and Host handling are what a TLS frontend needs.
Serve accepts a limited set of HTTPS ports; confirm the mapping was actually created with
tailscale serve status rather than assuming the port was allowed.
Give the forwarder the same lifetime as the hub. A backgrounded shell job dies on reboot while the
service comes back up, which leaves a hub that is running and unreachable over TLS; run it from
launchd or systemd alongside ocx service install.
Then connect with the two origins stated separately. The positional URL is the data origin —
it is where /readyz and /v1/catalog are fetched — and --management-url is the dashboard
origin used for pairing and key issuance. They do not have to share a port:
# This is exactly the line `ocx hub invite` prints, with the code filled in.echo '<pairing-code>' | ocx connect https://hub-name.tailnet-name.ts.net:8443 \ --management-url https://hub-name.tailnet-name.ts.net \ --pairing-code-stdinRecord those two origins on the hub as hub.dataPublicOrigin and hub.managementPublicOrigin, and
ocx hub invite will print them for you instead of asking you to remember them.
When --management-url is omitted it is taken from the /readyz response, which reports
hub.managementPublicOrigin. Setting it explicitly is clearer when the two origins differ.
Do not shortcut this by binding the data listener to 127.0.0.1. A loopback bind is how
opencodex recognizes a purely local deployment: it stops requiring a data credential, and it starts
requiring the request’s Host header to be loopback as well. A TLS frontend forwards
Host: hub-name.tailnet-name.ts.net, so /v1/catalog answers 403 origin_rejected — while
/readyz, which does not run that check, still returns 200. The deployment looks healthy and
cannot serve a model. Nothing in the request path reads X-Forwarded-Host, so the frontend cannot
repair it. Keep the listener on the tailnet address, where credential admission stays on and the
Host check does not apply.
That trap is about the bind, and it is still true. Getting a 127.0.0.1 socket on a hub for the
hub’s own processes is a different problem, and
unauthenticatedLoopbackListener is the sanctioned answer to
it: the public bind stays on the tailnet address with admission on, and a second socket serves local
callers. It is not a TLS target, for the reason given above.
Binding 0.0.0.0 also works and removes the need for a forwarder, since the listener is then
reachable on loopback as well. It publishes the data port on every interface, so prefer it only
where the host has no other network you care about — and note that the companion form of
unauthenticatedLoopbackListener is refused on a wildcard bind, because the public listener already
holds 127.0.0.1:<port> there.
Re-run the acceptance checks against the HTTPS data origin once Serve is up: /readyz, an
authenticated GET /v1/catalog, and one real routed response.
Operator-owned ts.net certificate proxy
Section titled “Operator-owned ts.net certificate proxy”If you operate your own TLS proxy, obtain a certificate only for the full ts.net FQDN:
tailscale cert hub-name.tailnet-name.ts.netProtect the private key, renew it through Tailscale’s supported mechanism, and proxy only to
127.0.0.1:10101. A generic TLS proxy does not supply trustworthy Tailscale identity. Do not
fabricate Tailscale-User-* headers; use the single-use, origin-bound pairing flow instead.
Inviting another machine
Section titled “Inviting another machine”Run this on the hub rather than writing an ocx connect line by hand:
ocx hub inviteIt mints a single-use, short-lived pairing code and prints the command to run on the other machine:
# Run on the other machine:echo '<code>' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --management-url https://hub-name.tailnet-name.ts.net --pairing-code-stdinThe data origin comes from --data-url, then hub.dataPublicOrigin, then the bind address. That
last fallback only works when the bind is an address another machine can dial: on a loopback or
wildcard bind it would resolve to http://localhost:<port>, which tells the other machine to dial
itself and spends the single-use code for nothing, so invite refuses instead and prints the
ocx config set hub.dataPublicOrigin line (plus the per-invite --data-url form). An explicit
--data-url or hub.dataPublicOrigin is never second-guessed — a loopback data origin is
legitimate over an SSH tunnel.
The management origin is hub.managementPublicOrigin, and on invite the --management-url flag
is a confirmation, not an override: the grant is bound to the configured origin and the
exchange compares against it, so a value that differs is refused with both origins named rather
than printing a code the hub would then reject.
Every successful invite also prints the bound browser origin on stderr. A grant is bound to one
origin, and a remote ocx connect presents Origin: http://localhost:<its own configured port>,
so if the bound origin is not the default http://localhost:10100 the other machine has to already
be running on that port before it runs the line — otherwise the hub refuses the exchange and the
code is spent. The note says which port, and offers admitting the default origin instead.
invite refuses before minting anything when the setup cannot work — a runtimeRole that is not
hub, a missing hub.managementPublicOrigin, a plaintext non-loopback management origin, a
malformed --data-url, a data origin that would be this machine’s own loopback, or no running
attested proxy. One precondition deserves its own paragraph.
corsAllowOrigins has to name the joining machine’s local browser origin. ocx connect sends
Origin: http://localhost:<its own proxy port> when it exchanges the grant, and grants are
origin-bound, so only hub.managementPublicOrigin itself or a loopback entry of corsAllowOrigins
can ever match. With neither present, invite exits non-zero, mints nothing, and names the exact
command:
ocx config set corsAllowOrigins '["http://localhost:10100"]'Use the port the joining machine’s proxy listens on; 10100 is the default. The setup block
above already sets it. A whole-array set replaces the array, so when the hub already has entries
run the line invite prints — it carries the existing ones plus the new origin. ocx config get corsAllowOrigins shows what is there now.
ocx hub invite --json emits { code, expiresAt, dataUrl, managementUrl, command } with expiresAt
as ISO 8601. The code is a secret: single-use, five-minute lifetime, rate-limited at the hub, and not
to be persisted, logged, or pasted into an issue. --clients codex,claude picks which client configs
the printed command will point at the hub.
invite is a convenience over the existing pairing flow, not a second mechanism. It drives the same
attested local route ocx gui pair uses, so it needs no admin token and nothing has to be exported
into your shell. Everything in Roles and direct data flow about
rotation, revocation and disconnect applies unchanged to a machine that joined this way.
macOS service operations
Section titled “macOS service operations”ocx service install and ocx service repair are safe to re-run against a live hub. A repair
renders the plist first and compares it: when the rendered bytes equal the bytes on disk, the token
file is unchanged, and launchctl print reports the job loaded from that plist, the repair re-asserts
0600, refreshes its install state, prints service is already loaded from the current plist; nothing to do. and returns — launchd is never touched. Earlier builds evicted a healthy job
unconditionally, which made a diagnostic command an outage.
ocx service restart is the verb that always restarts. It is no longer an alias of repair. It
runs the same refresh, and when that reloaded nothing — the healthy, unchanged case above — it
restarts the already-loaded job in place with launchctl kickstart -k, re-reads launchctl print to
confirm the job survived, and prints one line:
ocx service restart# ℹ️ service restarted (launchctl kickstart -k gui/501/com.opencodex.proxy).That is what to run after changing unauthenticatedLoopbackListener, hostname or port. The
kickstart opens no eviction window, so it is not the outage the old unconditional repair was.
A bare ocx service still selects repair, not restart: it is an idempotent “make it current”,
not a request to bounce a healthy hub. Use ocx service repair for the case it is actually for — a
job loaded from an older plist, or not loaded at all — and expect it to keep doing nothing on a
healthy one.
launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy by hand, or ocx service stop followed by
ocx service start, both still work and the error path names the first one as a fallback. Neither is
the recommended route any more.
Linux and Windows never had this gap: ocx service restart there ends in systemctl --user restart
and a stop-then-start of the scheduled task respectively, whichever verb asked.
ocx service status distinguishes four launchd states, and the last one is the one people misread:
| Summary | Meaning |
|---|---|
installed and loaded |
A domain answers and runs the command this plist bakes. Nominal. |
installed and loaded from an OLDER plist |
The job is running, from a definition that no longer matches. This is what ocx service repair is for. |
installed, not loaded |
Every domain answered “absent”, which is proof the job is gone. Repair re-registers it. |
installed; launchd state could not be verified |
launchctl could not be asked — for example from a context that cannot reach the gui/<uid> domain. This is not evidence the hub is down: nothing recommends a repair, and an unanswerable probe never marks a running proxy as dead. |
A probe that could not run used to be reported as “not loaded”, which told operators to repair a serving hub and let the updater start a competing proxy on the service’s own port.
Headless OAuth
Section titled “Headless OAuth”Disable browser launch on the hub:
ocx config set oauthOpenBrowser false- From the authenticated remote dashboard or management client, start
POST /api/oauth/loginfor the provider. The hub returns the authorization URL and instructions without opening a browser. - Open the URL on the operator’s machine and authorize there.
- If the loopback callback cannot reach the hub, paste the final redirect URL or code into the
dashboard/CLI. It sends
POST /api/oauth/login/codewith{provider,input}. - Poll the existing status endpoint until complete, then make a routed model request.
Never put the OAuth code in shell argv, logs, issue text, screenshots, or deployment evidence. The manual-code route keeps its existing unknown-provider, no-active-flow, invalid-code, and 4096-byte input checks.
Docker Compose
Section titled “Docker Compose”opencodex does not publish an official container image. The repository does maintain a source-build
Dockerfile,
compose.yaml, and a narrow
.dockerignore. The build pins the multi-platform Bun 1.4.0 image index by digest, runs the proxy as
the non-root bun user, keeps the root filesystem read-only, drops Linux capabilities, and publishes
only the data listener on the host’s 127.0.0.1:10100 by default. The foreground process uses
OCX_SERVICE=1, so stopping or recreating the container preserves routed Codex state instead
of restoring a native desktop configuration. Docker supplies supervision; no OS service manager
is installed in the image. Use Compose to restart/recreate the container; this does not extend
support to every dashboard restart path.
The image seeds a first-run hub configuration that binds the container listener to 0.0.0.0.
Before the first normal start, stream a freshly generated data-plane token into the bootstrap helper.
The helper accepts at most one 4096-byte line, never prints the token, refuses to replace an existing
token, and persists it as the canonical owner-only service-api-token in the ocx-state volume.
The deployment persists two separate homes: ocx-state at /home/bun/.opencodex for
OpenCodex configuration, provider credentials and usage, and codex-state at
/home/bun/.codex for Codex state and opencodex-catalog.json. The image and Compose
explicitly set CODEX_HOME=/home/bun/.codex, so this catalog path remains writable
with read_only: true and survives container recreation. The image creates both
directories for the non-root bun user with mode 0700; existing volume
ownership and permissions are not migrated automatically.
Do not combine CODEX_HOME and OPENCODEX_HOME: both products use an auth.json
filename with different formats. This packaging change adds persistence, not a
catalog generator. Materialize or import a valid catalog into
/home/bun/.codex/opencodex-catalog.json before the catalog acceptance check below;
without one, catalog_not_found remains the expected response.
Upgrading preserves the existing ocx-state volume and adds codex-state; no files
are migrated automatically. If a previous workaround placed a catalog directly
under /home/bun/.opencodex, back it up and deliberately copy only the catalog to
the new Codex home, preserving owner-only access. Do not copy either product’s
auth.json over the other. Deployments with a custom CODEX_HOME should retain
their explicit environment and writable volume mapping until migration is complete.
When overriding CODEX_HOME, mount that exact directory writable and persist the
default catalog at ${CODEX_HOME}/opencodex-catalog.json. If model_catalog_json
explicitly selects another file, that resolved path must also be persisted.
Keep the Compose project name stable during upgrades so the same named volumes are reused.
Mounts with existing foreign ownership, read-only mounts, and mounts using volume-nocopy
are not repaired by the image’s directory setup. Persist separately selected catalog or SQLite
paths separately; an OS credential store is not backed up by these two volumes.
When running without Compose, explicitly supply both named mounts. Dockerfile VOLUME
declarations alone create anonymous volumes that a later docker run does not automatically
reuse. These mount options use standalone example names; to reuse Compose data, substitute
its actual project-prefixed volume names:
--mount type=volume,src=ocx-state,dst=/home/bun/.opencodex \--mount type=volume,src=codex-state,dst=/home/bun/.codexInstall Git and Bun on the host first. Before every image build, run the existing canonical
generator from this Git checkout. It hashes Git-tracked working-tree sources (stage any newly
added source files first), not an arbitrary directory scan. Do not change source files between
generation and build. Only its untracked src/generated/compatibility-version.json artifact
enters the image; .git remains outside the Docker context. Do not commit or hand-edit the
manifest. The build rejects stale manifests: it verifies every recorded SHA-256 against the
read-only build context and again against the copied runtime files. It requires package.json,
bun.lock, and scripts/model-metadata.source.json; only that exact scripts artifact is
included, not the rest of scripts/. Missing or mismatched files, extra source files absent
from the manifest, and symlinks (including parent directories) fail the build. The only source
file exempt from the inventory is the generated manifest itself. If validation fails, reconcile
the tracked sources, remove unintended source files, and rerun the canonical generator.
git clone https://github.com/lidge-jun/opencodex.gitcd opencodexbun scripts/generate-compatibility-version.tsdocker compose buildopenssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.tsdocker compose up -dSet an alternate host port without changing the container’s fixed 10100 listener:
OPENCODEX_PORT=10190 docker compose up -dRemote access is an explicit opt-in. Set OPENCODEX_BIND_ADDRESS to the host’s LAN or Tailscale
IP, or use 0.0.0.0 to publish on all host interfaces:
OPENCODEX_BIND_ADDRESS=0.0.0.0 docker compose up -dUse a firewall and an authenticated TLS/tailnet frontend before exposing the port. The bind
override changes only the host publication; the container listener remains 0.0.0.0:10100.
Keep the same bind override on subsequent Compose invocations that recreate the hub. To update
an existing deployment, regenerate the manifest, run docker compose build, and recreate the
hub with docker compose up -d; do not repeat the one-time token initialization.
Configure providers with the dashboard through an operator-owned management frontend, or with one-shot CLI commands that share the state volume. The commands below show the existing Remote Hub settings; replace the example origin and identity before enabling them:
docker compose run --rm hub bun run src/cli/index.ts config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"'docker compose run --rm hub bun run src/cli/index.ts config set hub.managementIngress '{"enabled":true,"port":10101}'docker compose run --rm hub bun run src/cli/index.ts config set remoteGui.allowedTailscaleUsers '["operator@example.com"]'docker compose restart hubThese nested sets work because the image seeds a first-run hub configuration, so the object
already exists. On a fresh standalone install it does not, and the same lines fail until you create
it — see Linux systemd or macOS launchd above.
The container listener binds 0.0.0.0, so it is already reachable on the container’s own loopback
address and the companion form of unauthenticatedLoopbackListener does not apply there — it is
refused on a wildcard bind. The token bootstrap below is the container equivalent of the service’s
own provisioning step, and it likewise runs once.
Do not put a token in ARG, ENV, COPY, Compose YAML, image history, or command arguments. Do not
mount the Docker socket, the host’s home or Codex home, SSH agent, or provider-key files. A management
ingress bound to 127.0.0.1:10101 inside the container is reachable only by a TLS/tailnet frontend
in the same network namespace; never publish 10101 as a shortcut.
After the container is healthy, run a separate readiness promotion check:
docker compose exec hub bun -e \ "const r=await fetch('http://127.0.0.1:10100/readyz');console.log(r.status,await r.text());if(!r.ok)process.exit(1)"
docker compose exec hub bun -e \ "const t=(await Bun.file('/home/bun/.opencodex/service-api-token').text()).trim();const r=await fetch('http://127.0.0.1:10100/v1/catalog',{headers:{'x-opencodex-api-key':t}});console.log(r.status);if(!r.ok)process.exit(1)"Then send one real authenticated routed response with a configured model. If the secret is absent or unreadable, a non-loopback hub must not be accepted as ready. Never treat liveness alone as proof.
docker compose down removes the container and network but retains both named volumes. Treat
docker compose down --volumes as destructive: it deletes configuration, OAuth credentials, usage
history, the data-plane token, and persisted Codex state together.
Cross-platform CI builds the source image and checks startup, data-plane token admission, and container recreation using an isolated Compose project with throwaway credentials. It verifies that both named volumes and a synthetic catalog survive replacement. This check does not validate a real provider account, OAuth callback, custom mount migration, or every CPU architecture; perform the authenticated routed-response check above for your deployment.
Rollback
Section titled “Rollback”Inspect existing Serve mappings before changing them. tailscale serve reset removes every mapping
on the node; use a narrower supported removal command when unrelated mappings exist.
tailscale serve statustailscale serve resetocx config set hub.managementIngress '{"enabled":false}'ocx service repairFor a container rollback, retain both named state volumes and their mappings. An older image
can still use CODEX_HOME=/home/bun/.codex when that directory remains mounted; do not revert
to an older Compose file that drops the Codex mount. Do not merge the homes or rerun token bootstrap.
For a service rollback, stop the branch service and repair the prior release against the same
OPENCODEX_HOME. Disabling management ingress or Serve does not require changing the data listener.
Troubleshooting
Section titled “Troubleshooting”- Hub down:
ocx connect statusstill shows the saved connection.ocx disconnectcan restore local state offline; it cannot revoke the remote key. - Stale catalog:
ocx synckeeps a validated last-known-good catalog only for transient hub failures. Authentication, schema, size, and protocol failures are hard errors and never fall back to local providers. - Rotated token or
.prevrecovery: rerunocx connect rotatewith a pairing code or admin token. Do not edit or remove either token candidate before the recovery probe finishes. - Protocol mismatch: upgrade the older side named by the
hub-too-neworhub-too-oldmessage. Negotiation fails before token, catalog, journal, or client-state writes. - Lost or burned pairing code: run
ocx hub inviteagain. Grants are one-use and repeated failures are rate-limited without revealing whether a code exists. ocx hub invitesaysNo loopback browser origin is admitted for pairing: the hub admits no loopback browser origin, so an origin-bound grant could never match. Nothing was minted. Run theocx config set corsAllowOriginsline the error prints, with the joining machine’s proxy port. See Inviting another machine.ocx hub invitesays the advertised data origin would be this machine’s own loopback: the bind is loopback-only or a wildcard andhub.dataPublicOriginis unset, so there is no address to advertise and nothing guesses a tailnet or LAN one. Nothing was minted. Sethub.dataPublicOrigin, or pass--data-urlfor this invite only.- The joining machine’s exchange is refused and the code is spent: the grant was bound to an
origin that machine does not present. Re-read the
Bound browser origin:line from the invite — it names the port the other machine must be running on, or offers admittinghttp://localhost:10100on the hub instead. ocx hub inviterefuses a--management-url: on a hub that flag confirmshub.managementPublicOriginrather than overriding it, because the grant is bound to the configured value. Change the config, or drop the flag.ocx claudeon the hub launches native Codex/Claude, or the hub refuses to write its own client configs:unauthenticatedLoopbackListeneris off. The skip message names the gate. Enable the listener and restart the proxy (ocx service restarton a service install).ocx claudeon the hub gets404from the listener: the proxy is still the process that started before the listener’s wires existed, or before the port changed. Restart it withocx service restart— see macOS service operations.ocx service repairprintednothing to doand the process did not bounce (macOS): expected. A repair of a healthy job is deliberately a no-op. When you wanted a new process, runocx service restart, which kickstarts the loaded job in place and reportsservice restarted (launchctl kickstart -k …). Only if that fails islaunchctl kickstart -k gui/$(id -u)/com.opencodex.proxythe manual fallback — the failure message names it.ocx service installrefusesOPENCODEX_API_AUTH_TOKEN: that value is a management admin token.unset OPENCODEX_API_AUTH_TOKENand rerun; the service provisions its own data-plane token. See The data-plane token provisions itself.- The hub crash-loops at boot and
ocx statusshowsadmin-collision (file): theservice-api-tokenfile holds the management token, so the hub fences its management API closed. Delete the file and runocx service repairto provision a data-plane token. Unsetting the environment variable does not help here — the file is the source. - Plain HTTP refused: pairing over non-loopback HTTP is refused outright, and there is no flag that opts out of it. Put the management origin behind HTTPS, or pair over loopback. Admin tokens are never sent over HTTP.
403 origin_rejectedfrom/v1/catalogwhile/readyzreturns200: the data listener is bound to loopback behind a TLS frontend. See Giving the data listener TLS.- Remote session ended: sign in or pair again. Logout and expiry invalidate only the browser session, not a client data key.
- Outstanding revocation after disconnect: use the hub dashboard’s Integrations → API Keys page. It is the sole post-disconnect revocation path.
Usage from a connected client
Section titled “Usage from a connected client”ocx usage reads the connected hub with this client’s enrolled data key. Human output identifies the hub source and client-key scope; --json returns the same scoped data. Range, surface, provider/model filters and custom --since/--until bounds remain available. Account breakdowns and other clients’ records are not shared. An old or unavailable hub produces an explicit error instead of substituting local usage; upgrade the hub if it does not support this read.
The read-only data-plane endpoint is GET /v1/usage, using x-opencodex-api-key with a configured client key. Environment-wide and admin keys are refused. It accepts range, surface, provider, model, since, and until; unknown/repeated options and caller-selected key IDs are rejected. Oversized skipped rows retain the explicit incomplete-history warning.
Client usage credentials are sent only over HTTPS or loopback HTTP. Both the request and response disable caching.
Pairing this browser with a hub
Section titled “Pairing this browser with a hub”Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an ocx gui pair --origin command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code.
While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action.
If an auxiliary listener cannot bind, startup names unauthenticatedLoopbackListener or hub.managementIngress and the actual address. Correct that listener or free its address; changing only the public proxy port does not repair a fixed auxiliary port. Malformed hand-edited listener blocks warn and remain disabled while unrelated settings are preserved.

