From 5faaff7d101709ee343f2067c5274fed236395fd Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:45:52 +0900 Subject: [PATCH 1/2] fix(storage): publish cleanup manifests atomically [skip ci] Preserve complete recovery records across handled publication failures and keep existing partial-purge restoration boundaries. Refs #3778. Local checks deferred to final stack CI by maintainer instruction. --- .../content/docs/reference/management-api.md | 4 + src/storage/cleanup.ts | 84 +++++++----- structure/02_config-and-codex-home.md | 6 + tests/storage/storage-cleanup.test.ts | 123 ++++++++++++++++++ 4 files changed, 182 insertions(+), 35 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a12303cfd7..2cd23eb4bd 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -231,6 +231,10 @@ Storage cleanup endpoints can move or permanently remove archived session data. first and submit the returned digest. Prefer quarantine when recovery may be needed. ::: +Cleanup recovery manifests are published atomically, preserving the previous complete record +if a replacement fails before publication. This does not reverse a permanent purge: restore +can still fail when a recorded session has no surviving rollout file. + ### Models and catalog | Method and path | Purpose | Notable errors | diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index c39bbeedf1..e44f7d7b5c 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -30,11 +30,11 @@ import { writeSync, chmodSync, } from "node:fs"; -import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; import { resolveCodexHomeDir } from "../codex/home"; import { readThreadFieldsFromRollout } from "../codex/history-provider"; -import { renameAtomicFile } from "../config"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; export const ARCHIVED_SESSIONS_DIR = "archived_sessions"; export const TRASH_DIR = ".trash"; @@ -115,9 +115,35 @@ function chmodPrivatePath(path: string, mode: number): void { try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ } } -function writePrivateFile(path: string, content: string): void { - writeFileSync(path, content, "utf8"); - chmodPrivatePath(path, 0o600); +/** Publish complete stage metadata without truncating the last recovery record. */ +function writePrivateFile( + path: string, + content: string, + beforeRename?: (temporaryPath: string, targetPath: string) => void, +): void { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + let created = false; + try { + descriptor = openSync(temporaryPath, "wx", 0o600); + created = true; + writeFileSync(descriptor, content, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodPrivatePath(temporaryPath, 0o600); + beforeRename?.(temporaryPath, path); + renameAtomicFile(temporaryPath, path, undefined, "storage-cleanup"); + chmodPrivatePath(path, 0o600); + fsyncDirectoryBestEffort(dirname(path)); + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* preserve publication failure */ } + } + if (created) { + try { unlinkSync(temporaryPath); } catch { /* renamed or cleanup unavailable */ } + } + } } function chunkIds(ids: string[], chunkSize: number): string[][] { @@ -812,7 +838,6 @@ interface ReconcileTestHooks { const SATELLITE_BACKUP_FILE = "satellite-backup.json"; /** Marks an incomplete restore so retries can accept dest files and resume metadata. */ const RESTORE_PENDING_FILE = "restore-pending.json"; -let _satelliteBackupSeq = 0; type StagedFile = { from: string; to: string; relPath: string }; @@ -1070,34 +1095,11 @@ function writeSatelliteBackup( if (options?.failWrite) throw new Error("test_fail_satellite_backup_write"); const dest = join(stageDir, SATELLITE_BACKUP_FILE); const replacing = existsSync(dest); - const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`); - const payload = Buffer.from(JSON.stringify(backup), "utf8"); - const fd = openSync(tmp, "w", 0o600); - try { - let offset = 0; - while (offset < payload.length) { - offset += writeSync(fd, payload, offset, payload.length - offset, null); + writePrivateFile(dest, JSON.stringify(backup), () => { + if (options?.failReplaceBeforeRename && replacing) { + throw new Error("test_fail_satellite_backup_replace"); } - fsyncSync(fd); - } catch (error) { - try { closeSync(fd); } catch { /* */ } - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - closeSync(fd); - chmodPrivatePath(tmp, 0o600); - if (options?.failReplaceBeforeRename && replacing) { - try { unlinkSync(tmp); } catch { /* */ } - throw new Error("test_fail_satellite_backup_replace"); - } - try { - renameAtomicFile(tmp, dest, undefined, "storage-cleanup"); - } catch (error) { - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - chmodPrivatePath(dest, 0o600); - fsyncDirectoryBestEffort(stageDir); + }); } function clearSatelliteBackup(stageDir: string): void { @@ -1734,6 +1736,12 @@ export interface ExecuteCleanupOptions { /** Test-only failure injection for atomicity regressions. */ _test?: { failManifestWrite?: boolean; + /** Observe the complete temp and prior destination before publication. Never serialized. */ + beforeManifestReplace?: ( + temporaryPath: string, + targetPath: string, + phase: "staging" | "pre-commit" | "purge-incomplete", + ) => void; failPurgeBasenames?: string[]; failRollbackBasenames?: string[]; blockStageDestBasenames?: string[]; @@ -1752,14 +1760,14 @@ export interface ExecuteCleanupOptions { /** Serializable cleanup test hooks allowed on the management API wire. */ export type CleanupWireTestHooks = Omit< NonNullable, - "afterSatelliteMutations" | "beforeReconcileLock" + "afterSatelliteMutations" | "beforeReconcileLock" | "beforeManifestReplace" >; function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every(e => typeof e === "string"); } -/** Pick only allowlisted serializable hooks; drops function hooks (afterSatelliteMutations, beforeReconcileLock) and unknown keys. */ +/** Pick only allowlisted serializable hooks; drops all function hooks and unknown keys. */ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined { if (!raw || typeof raw !== "object") return undefined; const o = raw as Record; @@ -1911,6 +1919,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR entries: manifestEntries, ...extra, }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, extra.staging ? "staging" : "pre-commit", + ), ); }; @@ -1999,6 +2010,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR })) .filter(entry => entry.physicalRelPaths.length > 0), }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, "purge-incomplete", + ), ); } catch { /* best-effort: the pre-commit manifest is still on disk */ } return { diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index bb8ec5630f..66a729b4fe 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -127,6 +127,12 @@ Worker cannot restore unrelated API keys or provider settings from a snapshot re If that metadata write is unavailable after cleanup has already completed, the job retains the cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. +Cleanup manifests and satellite backups share the stage-local atomic publisher: an exclusive +private temporary file is fully written and file-synced before the existing Windows-tolerant +rename replaces the destination. Handled publication failures retain the previous record; +directory syncing remains best-effort. This does not make a partial permanent purge reversible: +restore still fails closed when a recorded logical entry has no surviving file. + Windows secret-file hardening resolves the effective token SID through an absolute, trusted PowerShell path before granting the owner and removing inherited broad ACL entries. The normal path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute diff --git a/tests/storage/storage-cleanup.test.ts b/tests/storage/storage-cleanup.test.ts index 421950368c..31cbcd6d21 100644 --- a/tests/storage/storage-cleanup.test.ts +++ b/tests/storage/storage-cleanup.test.ts @@ -8,6 +8,7 @@ import { readFileSync, renameSync, rmSync, + statSync, unlinkSync, utimesSync, writeFileSync, @@ -20,6 +21,7 @@ import { listArchivedCandidates, listTrashEntries, normalizeArchivedRolloutPath, + pickWireCleanupTestHooks, previewArchivedCleanup, previewExactArchivedCleanup, restoreTrashEntry, @@ -648,6 +650,127 @@ describe("executeArchivedCleanup", () => { expect(ids).toContain("told"); }); + test("initial manifest publication failure preserves originals and removes its private temp", () => { + home = buildHome(); + const observed: Array<{ priorExists: boolean; next: string; mode: number }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 881, + _test: { + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase !== "staging") return; + observed.push({ + priorExists: existsSync(targetPath), + next: readFileSync(temporaryPath, "utf8"), + mode: statSync(temporaryPath).mode & 0o777, + }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + // Assert outside the production catch: an assertion inside the hook could be swallowed. + expect(observed).toHaveLength(1); + expect(observed[0]!.priorExists).toBe(false); + expect(JSON.parse(observed[0]!.next).staging).toBe(true); + if (process.platform !== "win32") expect(observed[0]!.mode).toBe(0o600); + expect(result.error).toBe("fs_failed"); + expect(existsSync(join(home, ".trash", "881"))).toBe(false); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + expect(pickWireCleanupTestHooks({ + beforeManifestReplace: () => {}, failManifestWrite: true, + })).toEqual({ failManifestWrite: true }); + }, STORE_BUDGET_MS); + + test("failed pre-delete replacement leaves the prior manifest intact during publication and restorable", () => { + home = buildHome(); + let stagingBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 882, + _test: { + failRollbackBasenames: ["rollout-old.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "staging") stagingBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "pre-commit") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(stagingBytes); + expect(JSON.parse(observed[0]!.prior).staging).toBe(true); + expect(JSON.parse(observed[0]!.next).staging).toBeUndefined(); + expect(result.error).toBe("fs_failed"); + expect(result.trashDir).toBe(".trash/882"); + const stage = join(home, ".trash", "882"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(stagingBytes); + expect(readFileSync(join(stage, "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + const restored = restoreTrashEntry(".trash/882", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(restored.count).toBe(1); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + }, STORE_BUDGET_MS); + + test.each([false, true])("failed post-purge manifest replacement preserves prior bytes (partial=%s)", partial => { + home = buildHome(); + let preCommitBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(100, "permanent", home, { + now: 883, + _test: { + failPurgeBasenames: partial + ? ["rollout-mid.jsonl"] + : ["rollout-old.jsonl", "rollout-mid.jsonl", "rollout-new.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "pre-commit") preCommitBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "purge-incomplete") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(preCommitBytes); + expect(JSON.parse(observed[0]!.next).purgeIncomplete).toBe(true); + expect(JSON.parse(observed[0]!.next).entries).toHaveLength(partial ? 1 : 3); + expect(result.error).toBe("fs_failed"); + const stage = join(home, ".trash", "883"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(preCommitBytes); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const dbBefore = new Database(join(home, "state_5.sqlite"), { readonly: true }); + const rowsBefore = dbBefore.query("SELECT id FROM threads ORDER BY id").all(); + dbBefore.close(); + expect(rowsBefore).toEqual([{ id: "active" }]); + const stageBefore = readdirSync(stage).sort(); + const restored = restoreTrashEntry(".trash/883", { codexHome: home }); + if (partial) { + // A wholly purged old entry still fails closed; valid JSON is not full recovery. + expect(restored.error).toBe("fs_failed"); + expect(restored.restoredPaths).toEqual([]); + expect(readdirSync(stage).sort()).toEqual(stageBefore); + expect(readFileSync(join(stage, "rollout-mid.jsonl"), "utf8")).toBe("MID".repeat(20)); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual(rowsBefore); + dbAfter.close(); + } else { + expect(restored.ok).toBe(true); + expect(restored.count).toBe(3); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual([ + { id: "active" }, { id: "tmid" }, { id: "tnew" }, { id: "told" }, + ]); + dbAfter.close(); + } + }, { timeout: STORE_BUDGET_MS }); + test("rename-back failure keeps staged file and reports relative trashDir", () => { home = buildHome(); const db = new Database(join(home, "state_5.sqlite")); From 7b1ac51eb831c779d23118ee31dc3272f9c1bfae Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:50:09 +0900 Subject: [PATCH 2/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);