Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
**

!package.json
!bun.lock
!tsconfig.json

!src/
!src/**
Comment on lines +7 to +8

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 Exclude untracked source files from the image

Even immediately after generating the manifest, !src/** sends every untracked file under src into the build, while generate-compatibility-version.ts intentionally enumerates only git ls-files. An untracked module that shadows a tracked directory import (for example, foo.ts alongside tracked foo/index.ts) can consequently execute in the container without contributing to its compatibility identity. Build the source context from the tracked file list, or reject unexpected source files before accepting the image.

Useful? React with 👍 / 👎.

# Prepared on the host with the canonical Git-tracked-source generator.
!src/generated/compatibility-version.json

!docker/
!docker/**

!gui/
!gui/**
gui/node_modules/
gui/dist/
gui/.vite/

**/*.log
48 changes: 48 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1

# Keep the runtime aligned with package.json and pin the multi-platform image index.
ARG BUN_IMAGE=oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6

FROM ${BUN_IMAGE} AS build
WORKDIR /home/bun/app

COPY --chown=bun:bun package.json bun.lock tsconfig.json ./
RUN bun install --frozen-lockfile

COPY --chown=bun:bun gui/package.json gui/bun.lock ./gui/
RUN cd gui && bun install --frozen-lockfile

COPY --chown=bun:bun src ./src
COPY --chown=bun:bun docker ./docker
COPY --chown=bun:bun gui ./gui
RUN cd gui && bun run build

FROM ${BUN_IMAGE} AS runtime
WORKDIR /home/bun/app

ENV NODE_ENV=production \
OPENCODEX_HOME=/home/bun/.opencodex \
OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token

RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex
COPY --chown=bun:bun --chmod=0600 docker/config.json /home/bun/.opencodex/config.json

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
# Run `bun scripts/generate-compatibility-version.ts` on the host before building.
# Explicit COPY makes a missing artifact a build failure; .git stays outside the context.
COPY --chown=bun:bun src/generated/compatibility-version.json ./src/generated/compatibility-version.json
COPY --from=build --chown=bun:bun /home/bun/app/docker ./docker
COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist

USER bun
RUN ["bun", "-e", "import { readOpenCodexCompatibilityVersion } from './src/routing/compatibility/version.ts'; if (!/^[0-9a-f]{64}$/.test(readOpenCodexCompatibilityVersion() ?? '')) throw new Error('Missing or invalid generated compatibility manifest');"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify the manifest against the copied runtime files

When src/, package.json, or bun.lock changes after the last generator run—or an ignored manifest remains from an earlier checkout—this build check still succeeds: readOpenCodexCompatibilityVersion() only parses the manifest and hashes its contents; it never compares the recorded hashes with the files copied into the image. The resulting container therefore advertises a stale compatibility identity, so Lab evidence can be accepted for implementation bytes it was never produced against. Recompute and compare every manifest entry during the image build, failing on any mismatch.

Useful? React with 👍 / 👎.

VOLUME ["/home/bun/.opencodex"]
EXPOSE 10100

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --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"]
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled auto
ocx start # or `ocx service` to run it in the background
```

### Docker Compose

The repository ships a digest-pinned, non-root Compose build. With Git and Bun installed on the
host, generate the canonical compatibility manifest before every image build, then initialize
the data-plane token once through stdin and start the hub:

```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
bun scripts/generate-compatibility-version.ts
docker compose build
openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts
docker compose up -d
curl --fail --silent http://127.0.0.1:10100/healthz
curl --fail --silent http://127.0.0.1:10100/readyz
```

The default host binding is `127.0.0.1:10100`. Remote exposure requires explicit
`OPENCODEX_BIND_ADDRESS=<LAN-or-Tailscale-IP> docker compose up -d`; `0.0.0.0` opts into
all host interfaces. Restrict access with a firewall and an authenticated TLS/tailnet frontend.
The generated JSON stays untracked; it is copied into the image without including `.git`.
Regenerate it after source changes, and do not change the source between generation and build.

The token and mutable state stay in the `ocx-state` named volume; no credential is placed in the
image, Compose file, environment, or shell arguments. See the
[Remote Hub deployment guide](https://opencodex.me/guides/remote-hub/#docker-compose) for provider
setup, authenticated acceptance checks, remote management, and rollback.

<details>
<summary>Install from source (latest dev)</summary>

Expand Down
26 changes: 26 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: opencodex

services:
hub:
image: opencodex:local
build:
context: .
dockerfile: Dockerfile
target: runtime
init: true
read_only: true
ports:
- "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100"
volumes:
- ocx-state:/home/bun/.opencodex
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
stop_grace_period: 30s

volumes:
ocx-state:
47 changes: 47 additions & 0 deletions docker/bootstrap-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { writeServiceApiTokenFile } from "../src/lib/service-secrets";

const MAX_TOKEN_BYTES = 4096;
const MAX_INPUT_BYTES = MAX_TOKEN_BYTES + 2;

export async function readBoundedToken(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let raw = "";

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_INPUT_BYTES) throw new Error("token input exceeds 4096 bytes");
raw += decoder.decode(value, { stream: true });
}
raw += decoder.decode();
} finally {
reader.releaseLock();
}

const line = raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
if (/[\r\n\0]/.test(line)) throw new Error("token input must contain exactly one line");

const token = line.trim();
if (!token) throw new Error("token input is empty");
if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) throw new Error("token input exceeds 4096 bytes");
return token;
}

export async function bootstrapToken(stream: ReadableStream<Uint8Array>): Promise<void> {
const token = await readBoundedToken(stream);
writeServiceApiTokenFile(token);
}

if (import.meta.main) {
try {
await bootstrapToken(Bun.stdin.stream());
console.log("Initialized the owner-only data-plane token in the container state volume.");
} catch (error) {
console.error(`Token initialization failed: ${error instanceof Error ? error.message : "unknown error"}`);
process.exitCode = 1;
}
}
16 changes: 16 additions & 0 deletions docker/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"port": 10100,
"runtimeRole": "hub",
"hostname": "0.0.0.0",
"providers": {
"openai": {
"adapter": "openai-responses",
"baseUrl": "https://chatgpt.com/backend-api/codex",
"authMode": "forward",
"codexAccountMode": "pool"
}
},
"defaultProvider": "openai",
"codexAutoStart": false,
"codexShimAutoRestore": false
}
15 changes: 14 additions & 1 deletion docs-site/src/content/docs/fr/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,20 @@ La rotation garde les deux clés valides sous le même `apiKeyId` pendant dix mi

## Docker, retour arrière et dépannage

Il n’existe pas d’image Docker officielle. Épinglez l’image Bun par digest, conservez `/home/bun/.opencodex` dans un volume et montez le secret sur `/run/secrets/ocx_api_token`. Publiez seulement `10100`, jamais `10101`. Ne placez aucun secret dans `ARG`, `ENV`, `COPY`, Compose, l’historique d’image ou argv. Après le healthcheck, vérifiez séparément `/readyz`, le catalogue authentifié et une réponse réelle.
Il n’existe pas d’image Docker officielle, mais le dépôt fournit un `Dockerfile` et un `compose.yaml` maintenus pour construire localement une image Bun épinglée par digest. Initialisez une seule fois la clé de données via stdin ; elle est enregistrée avec des permissions réservées au propriétaire dans le volume `ocx-state` et n’est jamais affichée.

Installez Git et Bun sur l’hôte. Avant chaque construction, générez le manifeste canonique depuis les sources suivies par Git, sans modifier les sources entre la génération et la construction. Le JSON généré reste non suivi ; `.git` est exclu du contexte Docker. Le port hôte est lié à `127.0.0.1` par défaut. Pour un accès distant, utilisez explicitement `OPENCODEX_BIND_ADDRESS=<IP-LAN-ou-Tailscale> docker compose up -d` ; `0.0.0.0` expose toutes les interfaces. Protégez cet accès par un pare-feu et un frontal TLS/tailnet authentifié.

```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
bun scripts/generate-compatibility-version.ts
docker compose build
openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts
docker compose up -d
```

Le conteneur s’exécute avec l’utilisateur non-root `bun`, un système de fichiers racine en lecture seule et uniquement le port `10100` publié. Ne publiez jamais `10101` et ne placez aucun secret dans `ARG`, `ENV`, `COPY`, Compose, l’historique d’image ou argv. Après le healthcheck, vérifiez séparément `/readyz`, le catalogue authentifié et une réponse réelle. `docker compose down` conserve le volume ; `docker compose down --volumes` supprime aussi la configuration, les identifiants et la clé.

- Hub indisponible : `ocx disconnect` restaure localement, mais la révocation reste à faire.
- Catalogue périmé : seul un dernier catalogue validé est conservé après une panne transitoire; aucune substitution locale après erreur d’authentification, schéma, taille ou protocole.
Expand Down
127 changes: 58 additions & 69 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,85 +160,70 @@ Never put the OAuth code in shell argv, logs, issue text, screenshots, or deploy
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"]
## Docker Compose

opencodex does not publish an official container image. The repository does maintain a source-build
[`Dockerfile`](https://github.com/lidge-jun/opencodex/blob/main/Dockerfile),
[`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/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 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.

Install 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. A missing artifact fails the copy, and the runtime stage rejects an invalid identity.

```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
bun scripts/generate-compatibility-version.ts
docker compose build
openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts
docker compose up -d
```

Set an alternate host port without changing the container's fixed `10100` listener:

```bash
OPENCODEX_PORT=10190 docker compose up -d
```

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
Remote 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:

```bash
OPENCODEX_BIND_ADDRESS=0.0.0.0 docker compose up -d
```

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:
Use 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:

```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
docker compose restart hub
```

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.
Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or command arguments. Do not
mount the Docker socket, host home, 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:

Expand All @@ -247,12 +232,16 @@ 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)"
"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 the named volume. Treat
`docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage
history, and the data-plane token together.

## Rollback

Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping
Expand Down
15 changes: 14 additions & 1 deletion docs-site/src/content/docs/ja/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,20 @@ OAuth は `POST /api/oauth/login` で開始し、コールバックできない

## Docker とトラブルシューティング

公式 Docker イメージはありません。運用者が Bun イメージを digest 固定し、`/home/bun/.opencodex` をボリューム、`/run/secrets/ocx_api_token` を secret としてマウントしてください。公開するのは `10100` だけで、`10101` は公開しません。秘密値を `ARG`、`ENV`、`COPY`、Compose、イメージ履歴、argv に入れないでください。healthcheck 後にも readiness、認証済みカタログ、実リクエストを別途確認します。
公式 Docker イメージはありませんが、リポジトリには digest 固定の Bun イメージをローカルビルドするための、管理された `Dockerfile` と `compose.yaml` があります。初回起動前にデータキーを stdin から一度だけ初期化します。キーは表示されず、`ocx-state` ボリューム内に所有者限定の権限で保存されます。

ホストに Git と Bun が必要です。イメージをビルドするたびに、Git 管理下のソースから正規のマニフェストを生成し、生成後はビルドまでソースを変更しないでください。生成 JSON は Git に追加せず、`.git` は Docker コンテキストから除外します。ホスト側は既定で `127.0.0.1` にバインドします。リモート公開は `OPENCODEX_BIND_ADDRESS=<LANまたはTailscaleのIP> docker compose up -d` で明示的に指定し、`0.0.0.0` は全インターフェースを公開します。ファイアウォールと認証付き TLS/tailnet フロントエンドで保護してください。

```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
bun scripts/generate-compatibility-version.ts
docker compose build
openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts
docker compose up -d
```

コンテナは非 root の `bun` ユーザー、読み取り専用のルートファイルシステムで実行され、公開するのは `10100` だけです。`10101` は公開せず、秘密値を `ARG`、`ENV`、`COPY`、Compose、イメージ履歴、argv に入れないでください。healthcheck 後にも readiness、認証済みカタログ、実リクエストを別途確認します。`docker compose down` はボリュームを保持し、`docker compose down --volumes` は設定、認証情報、キーも削除します。

- hub 停止時はオフライン切断できますが、キー失効は未完了のままです。
- 一時障害時だけ検証済み LKG を維持し、認証・スキーマ・サイズ・プロトコル障害でローカルへフォールバックしません。
Expand Down
Loading
Loading