From 7b1ac51eb831c779d23118ee31dc3272f9c1bfae Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:50:09 +0900 Subject: [PATCH 1/2] fix(container): persist Codex home separately [skip ci] Carry #3747 for #3746 with isolated serializer regressions and explicit volume lifecycle documentation. Runtime image recreation remains unverified; final stack CI is pending. Co-authored-by: Ingwannu --- Dockerfile | 6 +- compose.yaml | 5 + .../src/content/docs/fr/guides/remote-hub.md | 21 ++++ .../src/content/docs/guides/remote-hub.md | 49 +++++++- .../src/content/docs/ja/guides/remote-hub.md | 22 ++++ .../src/content/docs/ko/guides/remote-hub.md | 22 +++- .../src/content/docs/ru/guides/remote-hub.md | 24 ++++ .../src/content/docs/tr/guides/remote-hub.md | 24 ++++ .../content/docs/zh-cn/guides/remote-hub.md | 19 +++ structure/02_config-and-codex-home.md | 18 +++ tests/service/container-bootstrap.test.ts | 116 ++++++++++++++++++ 11 files changed, 318 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5f648192d4..1ed000743d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,11 @@ WORKDIR /home/bun/app ENV NODE_ENV=production \ OPENCODEX_HOME=/home/bun/.opencodex \ + CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token -RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex +# These homes have incompatible auth.json formats; persist them without combining them. +RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex 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 @@ -46,7 +48,7 @@ COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist USER bun RUN ["bun", "docker/verify-compatibility.ts"] 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');"] -VOLUME ["/home/bun/.opencodex"] +VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"] EXPOSE 10100 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ diff --git a/compose.yaml b/compose.yaml index 8e25cf4cd0..b54795692a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,10 +9,14 @@ services: target: runtime init: true read_only: true + environment: + # A custom CODEX_HOME also requires a matching writable volume target below. + CODEX_HOME: /home/bun/.codex ports: - "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100" volumes: - ocx-state:/home/bun/.opencodex + - codex-state:/home/bun/.codex tmpfs: - /tmp:size=64m,mode=1777 security_opt: @@ -24,3 +28,4 @@ services: volumes: ocx-state: + codex-state: diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 8ab577619f..15d5392c18 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -60,6 +60,27 @@ La rotation garde les deux clés valides sous le même `apiKeyId` pendant dix mi ## Docker, retour arrière et dépannage +Lors d'un retour arrière, conservez les deux volumes et leurs points de montage. Les droits des volumes existants ne sont pas corrigés automatiquement. Consultez le [guide canonique](/guides/remote-hub/#docker-compose) pour les montages nommés hors Compose et les chemins d'état personnalisés. + +Deux volumes distincts conservent l'état : `ocx-state` pour +`OPENCODEX_HOME=/home/bun/.opencodex` et `codex-state` pour +`CODEX_HOME=/home/bun/.codex`. Leurs fichiers `auth.json` ont des formats incompatibles : +ne fusionnez pas ces répertoires. Ils restent accessibles en écriture malgré la racine en lecture seule. + +Le catalogue n'est pas généré automatiquement. Avant de tester `/v1/catalog` avec authentification, +créez ou importez un fichier valide dans `/home/bun/.codex/opencodex-catalog.json`. +Un répertoire vide renvoie normalement 404 `catalog_not_found`. Une mise à jour conserve +`ocx-state` et ajoute `codex-state`, sans déplacer les fichiers. Sauvegardez tout catalogue +précédemment placé dans `.opencodex`, puis transférez seulement ce catalogue avec des permissions +réservées au propriétaire ; ne remplacez pas un `auth.json` par celui de l'autre produit. +Si vous redéfinissez `CODEX_HOME`, montez ce répertoire exact en écriture et placez le catalogue +par défaut dans `${CODEX_HOME}/opencodex-catalog.json`. Si `model_catalog_json` désigne un autre +fichier, son chemin résolu doit aussi être persistant. Conservez les variables et montages +personnalisés jusqu'à la fin d'une migration explicite. +`docker compose down` conserve les deux volumes ; `docker compose down --volumes` supprime +`ocx-state` et `codex-state`, avec les identifiants, l'historique d'utilisation, la clé de données, +l'état et le catalogue Codex. Ce n'est pas une commande de mise à jour ou de redémarrage. + 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= 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é. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 800251ad4c..2334303be7 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -174,6 +174,45 @@ Before the first normal start, stream a freshly generated data-plane token into 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: + +```sh +--mount type=volume,src=ocx-state,dst=/home/bun/.opencodex \ +--mount type=volume,src=codex-state,dst=/home/bun/.codex +``` + 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 @@ -227,7 +266,7 @@ docker compose restart hub ``` 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 +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. @@ -244,9 +283,9 @@ docker compose exec hub bun -e \ 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` removes the container and network but retains both named volumes. Treat `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage -history, and the data-plane token together. +history, the data-plane token, and persisted Codex state together. ## Rollback @@ -260,7 +299,9 @@ 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 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. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index 022de50b12..cffc233143 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -60,6 +60,28 @@ OAuth は `POST /api/oauth/login` で開始し、コールバックできない ## Docker とトラブルシューティング +ロールバック時も両方のボリュームとマウント先を維持してください。既存ボリュームの所有者や権限は自動修復されません。Compose を使わない場合の名前付きマウントと独自の状態パスについては、[正本ガイド](/guides/remote-hub/#docker-compose)を参照してください。 + +状態は二つのボリュームに分けて永続化します。`ocx-state` は +`OPENCODEX_HOME=/home/bun/.opencodex`、`codex-state` は +`CODEX_HOME=/home/bun/.codex` に対応します。両製品の `auth.json` は形式が +異なるため、ホームを同じディレクトリにしないでください。読み取り専用の +ルートでも、この二つのホームは書き込み可能です。 + +カタログは自動生成されません。認証付き `/v1/catalog` の確認前に、有効な +`/home/bun/.codex/opencodex-catalog.json` を生成または取り込んでください。 +空のホームでは `catalog_not_found` の 404 が正常です。アップグレードは既存の +`ocx-state` を保持して `codex-state` を追加しますが、ファイルは自動移行しません。 +以前 `.opencodex` に置いたカタログはバックアップし、カタログだけを所有者限定の +権限で移してください。`auth.json` を相互に上書きしないでください。 +`CODEX_HOME` を変更する場合は、そのディレクトリ自体を書き込み可能なボリュームに +マウントし、既定のカタログを `${CODEX_HOME}/opencodex-catalog.json` に置きます。 +`model_catalog_json` で別のファイルを指定した場合は、その解決先も永続化します。 +カスタム構成は、明示的な移行が完了するまで環境変数とボリュームの対応を維持します。 +`docker compose down` は両ボリュームを保持しますが、`docker compose down --volumes` +は `ocx-state` と `codex-state` の両方を削除し、認証情報・使用履歴・データキー・ +Codex の状態とカタログも失われます。更新や再起動の代わりに使わないでください。 + 公式 Docker イメージはありませんが、リポジトリには digest 固定の Bun イメージをローカルビルドするための、管理された `Dockerfile` と `compose.yaml` があります。初回起動前にデータキーを stdin から一度だけ初期化します。キーは表示されず、`ocx-state` ボリューム内に所有者限定の権限で保存されます。 ホストに Git と Bun が必要です。イメージをビルドするたびに、Git 管理下のソースから正規のマニフェストを生成し、生成後はビルドまでソースを変更しないでください。生成 JSON は Git に追加せず、`.git` は Docker コンテキストから除外します。ホスト側は既定で `127.0.0.1` にバインドします。リモート公開は `OPENCODEX_BIND_ADDRESS= docker compose up -d` で明示的に指定し、`0.0.0.0` は全インターフェースを公開します。ファイアウォールと認証付き TLS/tailnet フロントエンドで保護してください。 diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index ffbb20f9c3..e924175672 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -86,6 +86,24 @@ ocx connect rotate --admin-token-stdin ## Docker +롤백할 때도 두 볼륨과 마운트 경로를 유지하세요. 기존 볼륨의 소유권과 권한은 자동으로 복구되지 않습니다. Compose 없이 실행할 때의 named volume 지정과 별도 상태 경로는 [영문 기준 가이드](/guides/remote-hub/#docker-compose)를 참고하세요. + +상태는 두 볼륨에 분리해 보관합니다. `ocx-state`는 +`OPENCODEX_HOME=/home/bun/.opencodex`, `codex-state`는 +`CODEX_HOME=/home/bun/.codex`에 연결됩니다. 두 제품의 `auth.json` 형식이 다르므로 +홈을 같은 폴더로 합치지 마세요. 루트 파일 시스템이 read-only여도 이 두 홈은 쓰기 가능합니다. + +카탈로그는 자동 생성되지 않습니다. 인증된 `/v1/catalog` 검사 전에 유효한 +`/home/bun/.codex/opencodex-catalog.json`을 생성하거나 가져와야 합니다. +빈 홈에서 `catalog_not_found` 404는 정상입니다. 업그레이드는 기존 `ocx-state`를 +유지하고 `codex-state`를 추가하지만 파일을 자동 이동하지 않습니다. 이전 우회 설정으로 +`.opencodex`에 둔 카탈로그는 백업한 뒤 카탈로그만 owner-only 권한으로 옮기세요. +두 제품의 `auth.json`을 서로 덮어쓰면 안 됩니다. 사용자 지정 `CODEX_HOME`은 그 정확한 +디렉터리를 쓰기 가능한 볼륨에 연결하고, 기본 카탈로그를 +`${CODEX_HOME}/opencodex-catalog.json`에 준비해야 합니다. `model_catalog_json`으로 +별도 파일을 지정했다면 그 경로도 영속 보관하세요. 명시적 이전이 완료되기 전까지는 +기존 사용자 지정 환경 변수와 볼륨 경로의 대응을 유지하세요. + opencodex는 공식 컨테이너 이미지를 배포하지 않지만, 저장소 루트의 `Dockerfile`과 `compose.yaml`로 digest가 고정된 소스 이미지를 직접 빌드할 수 있습니다. 최초 실행 전에 데이터 키를 stdin으로 초기화하세요. 키는 출력되지 않으며 `ocx-state` 볼륨의 owner-only `service-api-token`에 저장됩니다. 호스트에 Git과 Bun이 필요합니다. 이미지를 빌드할 때마다 Git이 추적하는 소스로 정식 매니페스트를 생성하고, 생성부터 빌드 사이에는 소스를 변경하지 마세요. 생성된 JSON은 Git에 추가하지 않으며 `.git`은 Docker 컨텍스트에서 제외됩니다. 호스트 포트는 기본적으로 `127.0.0.1`에 바인딩됩니다. 원격 공개는 `OPENCODEX_BIND_ADDRESS= docker compose up -d`로 명시적으로 선택하며, `0.0.0.0`은 모든 인터페이스에 공개합니다. 방화벽과 인증된 TLS/tailnet 프런트엔드로 보호하세요. @@ -101,11 +119,11 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트 홈, Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. +이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트의 홈이나 Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. 컨테이너 healthcheck의 `/healthz`가 통과한 뒤 `/readyz`, 인증된 `/v1/catalog`, 실제 모델 응답을 별도로 확인하세요. -`docker compose down`은 `ocx-state` 볼륨을 보존합니다. `docker compose down --volumes`는 설정, OAuth 인증 정보, 사용량 기록, 데이터 키를 함께 삭제하므로 파괴적 작업으로 취급하세요. +`docker compose down`은 `ocx-state`와 `codex-state`를 모두 보존합니다. `docker compose down --volumes`는 두 볼륨을 모두 삭제하여 설정, OAuth 인증 정보, 사용량 기록, 데이터 키, Codex 상태와 카탈로그를 지웁니다. 업그레이드나 재시작 대신 사용하지 마세요. ## 롤백과 문제 해결 diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index cc71e8efba..0887baf9a8 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -60,6 +60,30 @@ OAuth запускается через `POST /api/oauth/login`. Если callba ## Docker и устранение неполадок +При откате сохраняйте оба тома и их точки монтирования. Владельцы и права существующих томов не исправляются автоматически. Именованные тома вне Compose и отдельные пути состояния описаны в [основном руководстве](/guides/remote-hub/#docker-compose). + +Состояние хранится в двух отдельных томах: `ocx-state` для +`OPENCODEX_HOME=/home/bun/.opencodex` и `codex-state` для +`CODEX_HOME=/home/bun/.codex`. Форматы `auth.json` у двух продуктов несовместимы, +поэтому не объединяйте их домашние каталоги. Оба тома доступны для записи при +корневой файловой системе только для чтения. + +Каталог моделей автоматически не создаётся. Перед проверкой авторизованного +`/v1/catalog` создайте или импортируйте корректный файл +`/home/bun/.codex/opencodex-catalog.json`. Для пустого каталога состояния ответ +404 `catalog_not_found` ожидаем. Обновление сохраняет `ocx-state` и добавляет +`codex-state`, но не переносит файлы автоматически. Если обходное решение хранило +каталог моделей в `.opencodex`, сначала сделайте резервную копию, затем перенесите +только каталог моделей с доступом лишь для владельца. Не перезаписывайте один +`auth.json` другим. При переопределении `CODEX_HOME` монтируйте именно эту директорию +для записи и сохраняйте каталог по умолчанию в `${CODEX_HOME}/opencodex-catalog.json`. +Если `model_catalog_json` задаёт другой файл, его разрешённый путь также должен +храниться постоянно. До явного переноса сохраняйте прежнее соответствие переменных +окружения и томов. `docker compose down` сохраняет оба тома, а +`docker compose down --volumes` удаляет и `ocx-state`, и `codex-state`, включая +учётные данные, историю использования, ключ данных, состояние и каталог Codex. +Это разрушительная операция, а не способ обновления или перезапуска. + Официального Docker-образа нет, но репозиторий содержит поддерживаемые `Dockerfile` и `compose.yaml` для локальной сборки Bun-образа, закреплённого по digest. Перед первым запуском один раз передайте ключ данных через stdin; он не выводится и сохраняется с доступом только для владельца в volume `ocx-state`. На хосте нужны Git и Bun. Перед каждой сборкой создавайте канонический манифест из отслеживаемых Git исходников и не меняйте их до завершения сборки. Сгенерированный JSON не добавляйте в Git; `.git` исключён из контекста Docker. По умолчанию порт хоста привязан к `127.0.0.1`. Для удалённого доступа явно задайте `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` открывает все интерфейсы. Защитите доступ брандмауэром и аутентифицированным TLS/tailnet-фронтендом. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index 63f8e7e5c0..6499325908 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -60,6 +60,30 @@ Döndürme sırasında eski ve yeni anahtar aynı `apiKeyId` altında en fazla o ## Docker ve sorun giderme +Geri alırken iki volume'u ve bağlama yollarını koruyun. Mevcut volume sahipliği ve izinleri otomatik düzeltilmez. Compose dışındaki adlandırılmış bağlamalar ve özel durum yolları için [ana kılavuza](/guides/remote-hub/#docker-compose) bakın. + +Durum iki ayrı kalıcı volume'da tutulur: `ocx-state`, +`OPENCODEX_HOME=/home/bun/.opencodex` yoluna; `codex-state` ise +`CODEX_HOME=/home/bun/.codex` yoluna bağlanır. İki ürünün `auth.json` biçimleri +uyumsuzdur; bu dizinleri birleştirmeyin. Kök dosya sistemi salt okunur olsa da +bu iki volume yazılabilir durumda kalır. + +Katalog otomatik oluşturulmaz. Kimlik doğrulamalı `/v1/catalog` kontrolünden önce +`/home/bun/.codex/opencodex-catalog.json` konumunda geçerli bir katalog oluşturun +veya içe aktarın. Boş dizinde 404 `catalog_not_found` beklenen sonuçtur. Güncelleme +mevcut `ocx-state` volume'unu korur ve `codex-state` ekler; dosyaları otomatik taşımaz. +Önceden `.opencodex` içine konmuş kataloğu yedekleyin ve yalnızca katalog dosyasını, +sadece sahibine erişim veren izinlerle taşıyın. Bir ürünün `auth.json` dosyasını +diğerininkiyle değiştirmeyin. `CODEX_HOME` özelleştirilirse bu dizinin tam yolunu +yazılabilir bir volume'a bağlayın ve varsayılan kataloğu +`${CODEX_HOME}/opencodex-catalog.json` konumuna koyun. `model_catalog_json` başka +bir dosya seçiyorsa çözümlenen yol da kalıcı olmalıdır. Açık bir taşıma tamamlanana +kadar mevcut özel ortam ve volume eşlemesini koruyun. +`docker compose down` iki volume'u da korur; `docker compose down --volumes` hem +`ocx-state` hem `codex-state` ile birlikte kimlik bilgilerini, kullanım geçmişini, +veri anahtarını ve Codex durumunu/kataloğunu siler. Güncelleme veya yeniden başlatma +yerine kullanılmamalıdır. + Resmî Docker imajı yoktur; ancak depo, digest ile sabitlenmiş Bun imajını yerelde oluşturmak için bakımı yapılan bir `Dockerfile` ve `compose.yaml` sağlar. İlk başlatmadan önce veri anahtarını stdin üzerinden bir kez başlatın; anahtar yazdırılmaz ve `ocx-state` volume içinde yalnızca sahibinin okuyabileceği izinlerle saklanır. Host üzerinde Git ve Bun gereklidir. Her imaj derlemesinden önce Git tarafından izlenen kaynaklardan kanonik manifesti üretin ve derleme bitene kadar kaynakları değiştirmeyin. Üretilen JSON dosyasını Git'e eklemeyin; `.git` Docker bağlamının dışında kalır. Host portu varsayılan olarak `127.0.0.1` adresine bağlanır. Uzak erişim için açıkça `OPENCODEX_BIND_ADDRESS= docker compose up -d` kullanın; `0.0.0.0` tüm arayüzleri açar. Erişimi güvenlik duvarı ve kimlik doğrulamalı TLS/tailnet ön ucu ile koruyun. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 81e91d5be1..6caa44fc23 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -60,6 +60,25 @@ ocx connect rotate --admin-token-stdin ## Docker、回滚与排障 +回滚时也要保留两个卷及其挂载路径。已有卷的所有权和权限不会自动修复。有关不使用 Compose 时的命名卷挂载及单独的状态路径,请参阅[英文基准指南](/guides/remote-hub/#docker-compose)。 + +部署使用两个独立持久卷:`ocx-state` 对应 +`OPENCODEX_HOME=/home/bun/.opencodex`,`codex-state` 对应 +`CODEX_HOME=/home/bun/.codex`。两个产品的 `auth.json` 格式不同,不能合并到同一个 +主目录。即使根文件系统只读,这两个目录也可通过各自的卷写入。 + +此设置不会自动生成模型目录。在检查认证后的 `/v1/catalog` 前,必须生成或导入有效的 +`/home/bun/.codex/opencodex-catalog.json`;空目录返回 `catalog_not_found` 404 属于正常行为。 +升级会保留现有 `ocx-state` 并新增 `codex-state`,但不会自动迁移文件。若之前的临时方案 +将模型目录放在 `.opencodex` 下,请先备份,再仅迁移模型目录文件,并保留仅所有者可访问的权限。 +不要用一个产品的 `auth.json` 覆盖另一个。自定义 `CODEX_HOME` 时,必须将该确切目录挂载为 +可写持久卷,并在 `${CODEX_HOME}/opencodex-catalog.json` 准备默认目录文件。 +若 `model_catalog_json` 指向其他文件,也必须持久保存其解析后的路径。 +在明确完成迁移前,请保留已有的环境变量与卷路径映射。 +`docker compose down` 保留两个卷;`docker compose down --volumes` 则会删除 +`ocx-state` 和 `codex-state`,包括配置、凭据、用量记录、数据密钥及 Codex 状态和模型目录。 +这是破坏性操作,不能当作升级或重启命令使用。 + opencodex 不发布官方 Docker 镜像,但仓库提供维护的 `Dockerfile` 和 `compose.yaml`,用于在本地构建按 digest 固定的 Bun 镜像。首次启动前,通过 stdin 初始化一次数据密钥;密钥不会输出,并以仅所有者可读的权限保存在 `ocx-state` 卷中。 宿主机需要安装 Git 和 Bun。每次构建镜像前,都应从 Git 跟踪的源码生成规范兼容性清单,生成后到构建完成前不要修改源码。生成的 JSON 不加入 Git;`.git` 不进入 Docker 构建上下文。宿主机端口默认绑定 `127.0.0.1`。远程访问须显式使用 `OPENCODEX_BIND_ADDRESS= docker compose up -d`;`0.0.0.0` 会公开所有接口。请使用防火墙和经过身份验证的 TLS/tailnet 前端保护访问。 diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 66a729b4fe..ca27674904 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,24 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate +from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and +`ocx-state` respectively, retaining a read-only root. The image creates owner-only +writable homes for `bun`; existing volume ownership and permissions are not repaired. +The catalog resolver is unchanged; a writable empty home is not a materialized catalog. + +[Decision Log] +- 목적과 의도: Make the container's catalog location persistent and writable without changing native home semantics. +- 기존 구현 및 제약 조건: Compose persisted only the OCX home, leaving Codex state on a read-only root; both products use incompatible auth.json formats. +- 검토한 주요 대안: Merge the homes, nest Codex under an existing volume with a new startup initializer, or persist the existing separate Codex home. +- 선택한 방식: Add a separate codex-state volume and create both owner-only directories in the image. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves existing paths, avoids credential-file collisions, and works when an older ocx-state volume hides the image's seeded directory tree. +- 장점, 단점 및 영향: Two volumes must be backed up, but no automatic credential migration or runtime resolver change is needed. Catalog import/materialization remains an explicit prerequisite. + +`docker compose down` retains both volumes. `docker compose down --volumes` deletes +both `ocx-state` and `codex-state`, including their credentials and catalog/state; +treat it as destructive, not as an upgrade or restart command. + Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a later repair or uninstall look foreign even though the service and runtime were started from the diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index 237a2754ab..ada807d1d0 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -1,11 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { readBoundedToken } from "../../docker/bootstrap-token"; import { verifyCompatibilitySnapshot } from "../../docker/verify-compatibility"; import type { CompatibilityVersionManifest } from "../../scripts/generate-compatibility-version"; +import type { SerializedCatalog } from "../../src/server/catalog-download"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -39,6 +42,32 @@ describe("container token bootstrap", () => { }); describe("container deployment contract", () => { + test("persists separate OCX and Codex homes under the read-only root", () => { + const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { + services: { hub: { + environment: Record; volumes: string[]; read_only: boolean; + security_opt: string[]; cap_drop: string[]; + } }; + volumes: Record; + }; + const hub = compose.services.hub; + expect(hub.environment?.CODEX_HOME).toBe("/home/bun/.codex"); + expect(hub.read_only).toBe(true); + expect(hub.volumes).toContain("ocx-state:/home/bun/.opencodex"); + expect(hub.volumes).toContain("codex-state:/home/bun/.codex"); + expect(Object.hasOwn(compose.volumes, "ocx-state")).toBe(true); + expect(Object.hasOwn(compose.volumes, "codex-state")).toBe(true); + expect(hub.security_opt).toContain("no-new-privileges:true"); + expect(hub.cap_drop).toContain("ALL"); + + const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; + expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); + expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); + expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); + expect(runtime).toContain("USER bun"); + }); + test("publishes only the data port with loopback and explicit bind overrides", () => { const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { services: { hub: { ports: string[] } }; @@ -82,6 +111,93 @@ afterEach(() => { for (const dir of snapshotDirs.splice(0)) removeTreeWithRetry(dir); }); +function catalogHomeFixture(codexDirectory = "codex-state") { + const root = mkdtempSync(join(tmpdir(), "ocx-container-catalog-")); + snapshotDirs.push(root); + const ocxHome = join(root, "ocx-state"); + const codexHome = join(root, codexDirectory); + mkdirSync(ocxHome, { mode: 0o700 }); + mkdirSync(codexHome, { mode: 0o700 }); + const ocxAuth = '{"fixture":"ocx-oauth-store"}'; + const codexAuth = '{"fixture":"native-codex-store"}'; + writeFileSync(join(ocxHome, "auth.json"), ocxAuth, { mode: 0o600 }); + writeFileSync(join(codexHome, "auth.json"), codexAuth, { mode: 0o600 }); + const moduleUrl = pathToFileURL(repoPath("src/server/catalog-download.ts")).href; + const script = ` + const { serializePersistedCatalog } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(JSON.stringify(await serializePersistedCatalog())); + `; + const read = (): SerializedCatalog => { + // A fresh process keeps import-time home constants out of the parent test runner. + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoPath(), + env: { ...process.env, HOME: root, USERPROFILE: root, + OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome }, + encoding: "utf8", + timeout: 15000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(readFileSync(join(ocxHome, "auth.json"), "utf8")).toBe(ocxAuth); + expect(readFileSync(join(codexHome, "auth.json"), "utf8")).toBe(codexAuth); + return JSON.parse(result.stdout); + }; + return { root, ocxHome, codexHome, read }; +} + +function fixtureCatalog(slug: string) { + return { models: [{ slug, display_name: "Fixture", description: "fixture", priority: 1, + visibility: "list", base_instructions: "Fixture", input_modalities: ["text"] }] }; +} + +describe("container catalog home selection", () => { + test("reads only the Codex-home catalog across fresh processes without changing auth stores", () => { + const fixture = catalogHomeFixture(); + const catalog = fixtureCatalog("fixture/codex-home"); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.ocxHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/ocx-home")), { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + const serialized = fixture.read(); + expect(JSON.parse(serialized.body!)).toEqual(catalog); + expect(serialized.bytes).toBe(Buffer.byteLength(JSON.stringify(catalog), "utf8")); + expect(serialized.etag).toMatch(/^"[0-9a-f]{64}"$/); + // This proves a disk reread, not Docker volume initialization or container recreation. + expect(fixture.read()).toEqual(serialized); + }, 60000); + + test("uses a custom Codex home containing spaces", () => { + const fixture = catalogHomeFixture("custom codex state"); + const catalog = fixtureCatalog("fixture/custom-home"); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + }, 60000); + + for (const selection of ["relative", "absolute"] as const) { + test(`honors a ${selection} catalog override without falling back when it is absent`, () => { + const fixture = catalogHomeFixture(); + const selectedPath = selection === "relative" + ? join(fixture.codexHome, "catalogs", "custom.json") + : join(fixture.root, "external catalog.json"); + mkdirSync(dirname(selectedPath), { recursive: true, mode: 0o700 }); + const configuredPath = selection === "relative" ? "catalogs/custom.json" : selectedPath; + writeFileSync(join(fixture.codexHome, "config.toml"), `model_catalog_json = ${JSON.stringify(configuredPath)}\n`, { mode: 0o600 }); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/default")), { mode: 0o600 }); + const catalog = fixtureCatalog(`fixture/${selection}`); + writeFileSync(selectedPath, JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + unlinkSync(selectedPath); + expect(fixture.read().body).toBeNull(); + }, 60000); + } + + test("returns no catalog for malformed selected JSON without modifying auth stores", () => { + const fixture = catalogHomeFixture(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), "not JSON", { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + }, 60000); +}); + function compatibilitySnapshot() { const root = mkdtempSync(join(tmpdir(), "ocx-container-identity-")); snapshotDirs.push(root); From 1ddbc4e989b2240c4f1dc4adb407d4a5679e4248 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:50:54 +0900 Subject: [PATCH 2/2] docs(skill): keep plaintext keys out of agent recipes [skip ci] Carry #3324 from 2933cc5842 and 7734e758b7. Preserve prior exact revocation authority, benign commit/abort, and managed connect rotation. Add bounded literal recipe regression detector; this is guidance and static warning, not runtime enforcement. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- skills/ocx/SKILL.md | 25 ++++- skills/ocx/references/03_recipes.md | 55 +++++++++-- skills/ocx/references/05_remote_hub.md | 6 ++ tests/ci-workflows/skill-ocx.test.ts | 132 +++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a9975e7745..a5f19d861f 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -1,12 +1,12 @@ --- name: ocx -description: Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API. +description: "Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API." --- # Operating `ocx` `ocx` controls a locally running opencodex proxy. The CLI covers the dashboard's operational -surface, with one consent exception (starring) recorded under Consent below. `ocx capabilities` +surface, subject to Consent and Secret-bearing commands below. `ocx capabilities` lists the *declared* index, not every verb. Be precise about the gap, because guessing costs you more than reading: the capability index below @@ -90,6 +90,27 @@ starring would be useful, say so and let the user decide. The same boundary covers the session-gated `/api/codex-prompt` writes: read them with `ocx inspect codex-prompt`, and leave the writes to the dashboard. +## Secret-bearing commands + +**Do not create an access key or start an access-key rotation from an agent session.** +This covers the create and rotation-start operations under `ocx access key`, +`ocx access keys`, and `ocx api-key`, their `opencodex` equivalents and executable +wrappers, and direct POST requests to `/api/keys` and `/api/keys/rotate`. +Both text and JSON responses contain a one-time plaintext data-plane credential, +which can enter the agent transcript. Ask the user to perform that step in a +human-operated terminal outside the agent session, configure and verify the +replacement, and report only confirmation plus non-secret key/rotation IDs. +Never ask for the plaintext key in chat or offer a pipe, redirection, or API +workaround to perform the secret-returning step inside the agent session. + +Configuration confirmation is not approval to revoke the existing credential. +Identify the existing key ID and obtain separate explicit revocation approval +before committing an in-place rotation or removing an old, separately replaced key. +An existing explicit approval for that exact revocation remains valid; setup +confirmation alone does not supply it. Commit and abort return no plaintext key, +but still require authority for their state changes. Follow +[recipe 5](references/03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key). + ## Destructive verbs `storage trash restore` and `storage policy run` refuse without `--yes` (exit 2, nothing sent). diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 85b734a9ce..424ad34a53 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -94,20 +94,61 @@ Read `accounts[]`. Two things to respect: `providers[]` and `models[]` carry `estimatedCostUsd`. Costs are estimates; `estimateReasons` in the log rows tells you why (for example `usage_estimated`, `expected_price_overlay`). -## 5. Rotate an access key and confirm it went quiet +## 5. Prepare an access-key rotation without exposing the new key ```bash ocx access key list --json -ocx access key create rotated --json # the plaintext key is in THIS response only +``` + +Creating a key or starting a rotation returns a one-time plaintext credential in both text and +JSON output. **Do not perform either operation in an agent session**, including through the +aliases, executable wrappers, or management POST routes named in +[Secret-bearing commands](../SKILL.md#secret-bearing-commands). Ask the user to perform that step +in a terminal outside the agent session, configure and verify the replacement, and report only +configuration confirmation and the non-secret key/rotation IDs. Never ask for the key itself. + +Configuration confirmation is not revocation approval. Identify the existing key ID and obtain +separate explicit revocation approval before taking either path below. An existing explicit +approval for that exact revocation remains valid; do not ask again for the same action and ID. + +For an in-place rotation, commit the pending replacement on the same ID: + +```bash +ocx access key rotate commit --json +``` + +For a separately created replacement, remove only the old ID: + +```bash ocx access key remove --yes --json -ocx access key list --json # the old id is gone; check usage on the rest ``` -Note the argument style: `create ` and `remove ` are **positionals**, not `--label` and -`--id`. `remove` also refuses without `--yes`. +After the command succeeds, inspect the matching result: + +```bash +ocx access key list --json +``` + +For an in-place rotation, the same ID remains and `pendingRotation` disappears. For a separately +created replacement, the old ID disappears. The list alone does not prove the replacement accepts +traffic; use the user's successful connection verification as that evidence. `remove ` is +positional, not `--id`, and refuses without `--yes`. + +To cancel a pending rotation, with authority to discard the replacement: + +```bash +ocx access key rotate abort --json +``` + +Abort retains the old credential and removes the pending replacement. Re-list to inspect pending +state. On stale, mismatched, or expired rotation IDs, or an uncertain commit result, inspect +non-secret state and report the refusal or uncertainty. Do not start another rotation, delete the +entry, or retrieve a secret as automatic recovery. Missing pending state alone is not proof of a +successful commit: expiry and abort also clear it. -The list carries per-key usage, so a key whose count stops advancing is genuinely unused. The -plaintext key appears once, in the `create` response, and is never retrievable again. +The list carries per-key usage. A count that stops advancing shows no recorded new usage in that +observation window; it does not prove no client still needs the key. Creation and rotation-start +return the plaintext once; list does not return the full plaintext. An `ambiguous` footer on the list means two configured keys share an id, so per-key totals do not exist for them — do not attribute usage to either. diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 46b846d443..0c0596be07 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -114,6 +114,12 @@ The ordering is not ceremony. If the old key died at issuance, a client that had received the new key would be disconnected — and a disconnected client cannot be given a new key. So the contract is: apply the new key, verify the connection, then commit. +Raw access-key creation and rotation-start return plaintext and belong outside the agent +session; follow [recipe 5](03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key) +for the human handoff and separate revocation approval. The managed `ocx connect rotate` +flow returns non-secret status and is a distinct command, not permission to invoke the raw +secret-returning endpoint from an agent tool. + The token backup (`.prev`) is not deleted while a rotation is in flight, and commits only once both sides are confirmed to have accepted. diff --git a/tests/ci-workflows/skill-ocx.test.ts b/tests/ci-workflows/skill-ocx.test.ts index 9293dd080a..dbd9693dcc 100644 --- a/tests/ci-workflows/skill-ocx.test.ts +++ b/tests/ci-workflows/skill-ocx.test.ts @@ -163,3 +163,135 @@ describe("the consent boundary is stated, not implied", () => { expect(recipes).toContain("get approval"); }); }); + +describe("access-key recipes keep plaintext outside agent sessions", () => { + // CLI oracle: access.ts removes one --json before checking exact commit/abort tokens. + // These canonical spellings are case-sensitive; commit-old-id is a start, not a commit. + const secretBearingAccessKeyCommand = + /\b(?:ocx|opencodex)(?:\.(?:exe|mjs|cmd|ps1))?["']?\s+(?:access\s+keys?|api-key)\s+(?:create\b|rotate\b(?!\s+(?:--json\s+)?(?:commit|abort)(?=\s|$)))/gm; + const secretBearingManagementRequest = + /(?:(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["'])[^\n]{0,240}\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])|\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])[^\n]{0,240}(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["']))/gim; + + /** + * Early warning for literal recipes in ordinary fences and single-backtick spans. + * Not a shell/JS parser: implicit POSTs, dynamic calls, alternate Markdown and + * arbitrary multiline requests remain outside this bounded detector. + */ + function secretBearingCommandsInCode(text: string): string[] { + const spans: string[] = []; + const prose = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_all: string, body: string) => { + spans.push(body); + return ""; + }); + for (const span of prose.matchAll(/`([^`\n]+)`/g)) spans.push(span[1]!); + const matches: string[] = []; + for (const span of spans) { + const executable = span.replace(/(?:\\|`|\^)\r?\n\s*/g, " "); + matches.push(...Array.from(executable.matchAll(secretBearingAccessKeyCommand), match => match[0])); + matches.push(...Array.from(executable.matchAll(secretBearingManagementRequest), match => match[0])); + } + return matches; + } + + test("all key aliases reject creation/start and preserve non-secret commit/abort", () => { + for (const binary of ["ocx", "opencodex"]) { + for (const group of ["access key", "access keys", "api-key"]) { + const prefix = `${binary} ${group}`; + for (const action of [ + "create rotated", "create rotated --json", + "rotate old-id", "rotate old-id --json", "rotate --json old-id", + ]) { + const command = `${prefix} ${action}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + for (const operation of ["commit", "abort"]) { + for (const args of [ + `${operation} old-id rotation-id`, + `${operation} old-id rotation-id --json`, + `--json ${operation} old-id rotation-id`, + ]) { + const command = `${prefix} rotate ${args}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + const start = `${prefix} rotate --json ${operation}-old-id`; + expect(secretBearingCommandsInCode("`" + start + "`"), start).toHaveLength(1); + } + } + } + }); + + test("wrappers, shell continuations and inline examples cannot hide literal commands", () => { + for (const command of [ + "& ocx access keys create rotated --json", + "command ocx access key create rotated", + "env ocx api-key rotate old-id", + "& 'C:\\Tools\\opencodex.exe' api-key rotate old-id", + "node /opt/bin/ocx.mjs access key create rotated", + "ocx.cmd access key create rotated", + "& './opencodex.ps1' access keys rotate old-id", + "ocx access key \\\n create rotated --json", + "ocx access key `\r\n create rotated --json", + "ocx access key ^\n rotate old-id", + "ocx access key rotate COMMIT", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + expect(secretBearingCommandsInCode("Run `ocx api-key create rotated --json` next.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Do not run `ocx api-key create rotated --json`.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Creation under `ocx access key` returns plaintext.")).toEqual([]); + }); + + test("explicit management POST recipes are detected without banning commit or abort", () => { + for (const route of ["/api/keys", "/api/keys/rotate"]) { + for (const command of [ + `POST ${route}`, + `curl -X POST http://127.0.0.1:3000${route}`, + `curl 'http://127.0.0.1:3000${route}?source=recipe' --request POST`, + `curl --request POST \\\n 'http://127.0.0.1:3000${route}#example'`, + `Invoke-RestMethod http://127.0.0.1:3000${route} -Method Post`, + `Invoke-WebRequest -Method Post http://127.0.0.1:3000${route}`, + `fetch('${route}', { method: 'POST' })`, + ]) { + expect(secretBearingCommandsInCode("```text\n" + command + "\n```"), command).toHaveLength(1); + } + } + expect(secretBearingCommandsInCode("Run `POST /api/keys` next.")).toHaveLength(1); + for (const command of [ + "ocx access key list --json", + "ocx access key remove old-id --yes --json", + "ocx connect rotate --admin-token-stdin --json", + "curl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + "curl -X DELETE http://127.0.0.1:3000/api/keys/rotate", + "curl -X DELETE http://127.0.0.1:3000/api/keys", + "curl http://127.0.0.1:3000/api/keys\ncurl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + expect(secretBearingCommandsInCode("| POST | `/api/keys/rotate` |")).toEqual([]); + }); + + test("the original unsafe recipe is detected and every shipped page is scanned", () => { + const original = "```bash\nocx access key list --json\nocx access key create rotated --json\n" + + "ocx access key remove --yes --json\nocx access key list --json\n```"; + expect(secretBearingCommandsInCode(original)).toHaveLength(1); + for (const file of ["SKILL.md", ...REFERENCES.map(ref => join("references", ref))]) { + expect(secretBearingCommandsInCode(read(file)), file).toEqual([]); + } + }); + + test("guidance distinguishes configuration confirmation from revocation authority", () => { + // Documentation presence/order only: these assertions do not prove agent behavior. + const skill = readFileSync(SKILL, "utf8"); + const recipes = read("references/03_recipes.md"); + for (const text of [skill, recipes]) { + expect(text).toMatch(/outside the agent\s+session/); + expect(text).toMatch(/configuration confirmation is not (?:revocation )?approval/i); + expect(text).toMatch(/existing explicit\s+approval for that exact revocation remains valid/); + } + const approvalAt = recipes.indexOf("separate explicit revocation approval"); + expect(approvalAt).toBeGreaterThanOrEqual(0); + for (const command of ["ocx access key rotate commit", "ocx access key remove"]) { + expect(recipes.indexOf(command)).toBeGreaterThan(approvalAt); + } + }); +});