Skip to content
34 changes: 34 additions & 0 deletions devlog/_plan/260827_remote_hub/090_dogfood_record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 090 — Dogfood record: clisu-oracle hub + MacBook client (2026-08-28)

Branch build @ f98081fbf. Hub: clisu-oracle (aarch64), OPENCODEX_HOME=~/.opencodex-hub,
bind 100.100.245.81:10190, data token file-fed, remoteGui.allowInsecureHttp=true,
hub.managementPublicOrigin=http://100.100.245.81:10190, corsAllowOrigins += http://localhost:10100.
Client: this MacBook, isolated OPENCODEX_HOME/CODEX_HOME under /tmp/ocx-dogfood-SzfA
(real user config untouched; the temp grok rewrite from the earlier standalone probe was
reverted to :10100).

Proven end-to-end (commands + outputs in session log):
1. /readyz over tailnet: status ready, protocol 1, managementUrl advertised.
2. /v1/catalog over tailnet: 401 without token; 200 + strong ETag + Cache-Control
private,no-cache with the data token (516 KB).
3. Admin token over plain HTTP refused by connect ("Admin credentials may be sent only
over HTTPS") — HTTPS-only admin rule enforced live.
4. ocx gui pair --origin http://localhost:10100 issued a single-use grant (json shape).
5. ocx connect <hub> --pairing-code-stdin --allow-insecure-http --clients codex:
full transaction — grant exchanged, per-client key 085da5fb… auto-issued, key stored
ONLY in service-api-token (0600, 50 bytes), catalog placed atomically (262 KB),
dedicated provider block injected (base_url hub, env_key contract, absolute
model_catalog_json), client state committed with apiKeyId.
6. Real routed completion through the hub with the per-client key: gpt-5.6-luna answered
"HUB_OK" (chat.completions 200).
7. Usage attribution on the hub: the request row carries apiKeyId 085da5fb…,
admissionKind configured — per-machine slice works.
8. ocx disconnect: injected config restored byte-identically to the seeded original,
token file deleted, client state cleared, reminder to revoke the still-valid key via
hub GUI (by design — operator-owned revocation).

Three live defects found and fixed during dogfood (each with a regression test):
- 596bb02f3 runtimeRole=hub refused ocx start (state read).
- 19eb6a4bd hub role ran local client syncs on start (readyz failed + grok rewrite).
- f98081fbf connect refused to commit on a fresh machine with no config.json.

1 change: 1 addition & 0 deletions docs-site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default defineConfig({
label: "Guides",
translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" },
items: [
{ label: "Remote Hub Deployment", slug: "guides/remote-hub" },
{ label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" },
{ label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" },
{ label: "Model Routing", translations: { fr: "Routage des modèles", ko: "모델 라우팅", "zh-CN": "模型路由", "zh-TW": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング", tr: "Model Yönlendirme" }, slug: "guides/model-routing" },
Expand Down
229 changes: 229 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
---
title: Remote Hub Deployment
description: Run an opencodex hub on Linux, macOS, or Docker with a loopback-only management ingress, Tailscale Serve, and headless OAuth.
---

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 management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. 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

- 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-token` file or
`OCX_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-Login` is trusted only on the separately bound management ingress. The same header
on the public listener is ignored. `remoteGui.allowedTailscaleUsers` controls session issuance; it
does not create a new general-purpose principal.

## Linux systemd or macOS launchd

Choose the hub's Tailscale address for the data listener and the exact browser-visible HTTPS origin
for management. The values below are examples:

```bash
ocx config set runtimeRole hub
ocx config set hostname 100.64.0.10
ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"'
ocx config set corsAllowOrigins '["http://localhost:10100"]'
ocx config set hub.managementIngress '{"enabled":true,"port":10101}'
ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]'

# Generate/read this in a protected operator shell or secret manager.
# It is a data-admission token, not a provider credential.
export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"
ocx service install
ocx service status
```

`ocx service install` copies the token into the existing owner-only `service-api-token` path. The
launchd plist and 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.

Prove liveness and readiness on the public data listener:

```bash
curl --fail --silent http://100.64.0.10:10100/healthz
curl --fail --silent http://100.64.0.10:10100/readyz
```

A `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

First prove the management socket is loopback-only, then publish it through Serve:

```bash
ss -ltnp | grep 10101 # Linux: expected 127.0.0.1:10101 only
lsof -nP -iTCP:10101 -sTCP:LISTEN # macOS: expected 127.0.0.1 only

tailscale serve --bg --https=443 http://127.0.0.1:10101
tailscale serve status
```

Set `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:

```bash
# 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/null
```

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

### Operator-owned ts.net certificate proxy

If you operate your own TLS proxy, obtain a certificate only for the full ts.net FQDN:

```bash
tailscale cert hub-name.tailnet-name.ts.net
```

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

## Headless OAuth

Disable browser launch on the hub:

```bash
ocx config set oauthOpenBrowser false
```

1. From the authenticated remote dashboard or management client, start `POST /api/oauth/login` for
the provider. The hub returns the authorization URL and instructions without opening a browser.
2. Open the URL on the operator's machine and authorize there.
3. 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/code` with `{provider,input}`.
4. 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.

## Operator-owned Docker recipe

opencodex does not publish or maintain an official container image. The following recipe is an
operator-owned starting point. Before building, resolve `oven/bun:1.4.0` to a registry digest and
replace both `REPLACE_WITH_BUN_1_4_0_DIGEST` values. A tag alone is not a production pin.

```dockerfile
# syntax=docker/dockerfile:1
FROM oven/bun:1.4.0@sha256:REPLACE_WITH_BUN_1_4_0_DIGEST AS build
WORKDIR /home/bun/app
COPY --chown=bun:bun package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY --chown=bun:bun src ./src
COPY --chown=bun:bun gui ./gui
COPY --chown=bun:bun tsconfig.json ./
RUN cd gui && bun install --frozen-lockfile && bun run build

FROM oven/bun:1.4.0@sha256:REPLACE_WITH_BUN_1_4_0_DIGEST AS runtime
WORKDIR /home/bun/app
ENV OPENCODEX_HOME=/home/bun/.opencodex
ENV OCX_API_TOKEN_FILE=/run/secrets/ocx_api_token
COPY --from=build --chown=bun:bun /home/bun/app/package.json ./package.json
COPY --from=build --chown=bun:bun /home/bun/app/bun.lock ./bun.lock
COPY --from=build --chown=bun:bun /home/bun/app/node_modules ./node_modules
COPY --from=build --chown=bun:bun /home/bun/app/src ./src
COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist
USER bun
VOLUME ["/home/bun/.opencodex"]
EXPOSE 10100
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["bun", "-e", "const r=await fetch('http://127.0.0.1:10100/healthz');if(!r.ok)process.exit(1)"]
CMD ["bun", "run", "src/cli/index.ts", "start", "--port", "10100"]
```

An example Compose definition keeps mutable state and the token outside the image:

```yaml
services:
hub:
build: .
read_only: true
ports:
- "10100:10100"
volumes:
- ocx-state:/home/bun/.opencodex
tmpfs:
- /tmp
secrets:
- source: ocx_api_token
target: ocx_api_token
uid: "1000"
gid: "1000"
mode: 0440
restart: unless-stopped

volumes:
ocx-state:

secrets:
ocx_api_token:
file: ./secrets/ocx_api_token
```

Initialize the named volume before the first normal start. Container port publishing requires the
data listener to bind `0.0.0.0`; the management listener remains fixed to container loopback:

```bash
docker compose run --rm hub bun run src/cli/index.ts config set runtimeRole hub
docker compose run --rm hub bun run src/cli/index.ts config set hostname 0.0.0.0
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 up -d
```

Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or the command line. Do not
mount the Docker socket, host home, Codex home, SSH agent, or provider-key files. Publish only port
`10100`. 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:

```bash
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('/run/secrets/ocx_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.

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

```bash
tailscale serve status
tailscale serve reset
ocx config set hub.managementIngress '{"enabled":false}'
ocx service repair
```

For a container rollback, remove or replace the container while retaining the named state volume.
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.
3 changes: 3 additions & 0 deletions src/cli/claude-agent-startup-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export async function syncClaudeAgentDefsAtProxyStartup(
const warn = deps.warn ?? (message => console.warn(message));

try {
// Hub role: never rewrite this host's ~/.claude roster on startup (same rule as
// shouldSyncCodexOnStart / shouldSyncGrokOnStart — the hub serves other machines).
if (config.runtimeRole === "hub") return null;
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
return inject(config, {});
}
Expand Down
21 changes: 18 additions & 3 deletions src/client/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { readFileSync } from "node:fs";
import {
getConfigPath,
deleteConfigTopLevelKey,
getDefaultConfig,
mutatePersistedConfig,
readConfigDiagnostics,
saveConfig,
} from "../config";
import type { OcxClientConnectionConfig } from "../types";

Expand Down Expand Up @@ -38,9 +40,10 @@ export function readClientConnectionState(): ClientConnectionState {
return { kind: "invalid", reason: "config.json.runtimeRole is invalid" };
}
if (!hasClient && (role === undefined || role === "standalone")) return { kind: "disconnected" };
if (!hasClient && role === "hub") {
return { kind: "mismatched", reason: "runtimeRole=hub cannot be used as a connected client" };
}
// A hub is a server role, not a broken client: without client state it simply is not
// connected, and refusing here blocked `ocx start` on every hub (found on the first
// clisu-oracle dogfood boot). Hub role WITH client state remains mismatched below.
if (!hasClient && role === "hub") return { kind: "disconnected" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep hub role out of disconnected client state

When a hub has no client block, classifying it as disconnected lets connectClient() pass its state.kind === "disconnected" preflight and later makes commitClientConnection() replace runtimeRole: "hub" with "client". Thus, on a hub without a service-token file, running ocx connect silently converts the installation into a client and disables hub behavior on restart. Preserve the hub/client mismatch for connection operations and special-case hub startup in handleStart() instead, or add an explicit hub-role rejection to the connect preflight.

Useful? React with 👍 / 👎.

if (!hasClient || role !== "client") {
return {
kind: "mismatched",
Expand Down Expand Up @@ -70,6 +73,18 @@ export function commitClientConnection(
return { changed: !unchanged, value: undefined };
});
if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status;
if (outcome.status === "unavailable" && outcome.reason === "missing") {
// First ocx run on a fresh machine: ocx connect is the expected first command in
// client mode, so there is no config.json yet. mutatePersistedConfig correctly
// refuses to invent one (a lost config must fail closed), but a genuinely absent
// file is the bootstrap case, not corruption — seed defaults plus the client
// block atomically. Found on the first MacBook↔oracle dogfood connect.
const seeded = getDefaultConfig();
seeded.runtimeRole = "client";
seeded.client = structuredClone(state);
saveConfig(seeded);
Comment on lines +82 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make missing-config bootstrap conditional

When mutatePersistedConfig() reports missing, it returns before acquiring the mutation lock; another first-run command can therefore create a valid config.json before this unconditional whole-config saveConfig(seeded) runs. The seeded defaults then overwrite the newly created providers, credentials, and settings. Bootstrap creation needs to acquire the coordinator lock and confirm the file is still absent before writing, otherwise it should retry the field-scoped mutation or report a conflict.

Useful? React with 👍 / 👎.

return "committed";
}
throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`);
}

Expand Down
16 changes: 13 additions & 3 deletions src/codex/desired-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,14 @@ export function codexIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrati
}

/** Whether a Codex sync is permitted for this admitted config snapshot. */
export function shouldSyncCodexOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
export function shouldSyncCodexOnStart(config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">): boolean {
// A hub is a server for OTHER machines: it must not rewrite its own host's
// Codex/Claude/Grok client configs on startup (interview decision Q6, and the
// first clisu-oracle dogfood boot proved the failure mode — the hub marked
// /readyz failed because it tried to run the full local client sync).
// "Hub is also a client" stays possible by explicitly enabling integrations
// later; the ROLE alone never injects.
if (config.runtimeRole === "hub") return false;
return codexIntegrationEnabled(config);
}

Expand Down Expand Up @@ -182,7 +189,7 @@ export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesir
*/
export async function syncCodexOnStartIfEnabled(
port: number,
config: Pick<OcxConfig, "clientIntegrations">,
config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">,
sync: CodexStartupSync = defaultStartupSync,
readinessGate?: ReadinessGate,
): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> {
Expand Down Expand Up @@ -225,6 +232,9 @@ async function defaultStartupSync(port: number): Promise<CodexStartupSyncOutcome
* startup and its diagnostic is worth printing. This only answers whether to
* attempt the sync at all.
*/
export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">): boolean {
// Same hub rule as shouldSyncCodexOnStart: the hub role never rewrites its
// host's client configs on startup.
if (config.runtimeRole === "hub") return false;
return grokIntegrationEnabled(config);
}
Loading
Loading