diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 87b336eea62..b31a0859afc 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -132,6 +132,16 @@ export async function syncModelsToCodex( // the injector, before it gets a chance to create any artifact. const admission = (deps.admitCodexWrite ?? admitCodexWrite)(); if (admission.kind === "refused" && admission.authority === "service-home") { + // Startup turns this result into a failed /readyz and records nothing else, and + // `ocx sync` only says the sync did not complete, so an unattended refusal must + // leave one line behind (#5782). The admission message can name private home and + // service-definition paths, so the log carries the authority only; POST /api/sync + // returns the specific reason to an authenticated operator. + log?.error( + "[opencodex] Codex write refused (service-home): this proxy could not prove it owns the " + + "installed service homes, so catalog sync and Codex config injection were skipped. " + + "POST /api/sync returns the specific reason.", + ); return { status: "refused", authority: "service-home", diff --git a/structure/config.md b/structure/config.md index 79deb53630f..1ae6fc51842 100644 --- a/structure/config.md +++ b/structure/config.md @@ -270,6 +270,7 @@ missing on disk (presence only; a present key with another value is left alone). `ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore leave the existing catalog and cache untouched, and their concrete messages are emitted on stderr. +The earlier service-home admission refusal logs one path-free line; `POST /api/sync` returns its reason. Exactly one conversation-history refusal scopes the relabel unit instead of vetoing the apply transition, and only because it is permanent. Codex allocates paginated rollout ordinals inside its own writer, so `history_paginated_requires_native_writer` is not retryable: when the admitted diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index b19ef2ae55f..c632b6df225 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -165,6 +165,41 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([]); }); + test("a service-home refusal leaves one path-free log line and writes nothing (#5782)", async () => { + const privatePath = "/srv/private-operator/.opencodex"; + const logs: string[] = []; + const errors: string[] = []; + let refreshed = false; + let injected = false; + const result = await syncModelsToCodex(12345, config, { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, { + admitCodexWrite: () => ({ + kind: "refused" as const, + authority: "service-home" as const, + message: `Refusing to write: a service is installed for OPENCODEX_HOME=${privatePath}.`, + }), + refreshCodexModelCatalog: async () => { + refreshed = true; + throw new Error("must not refresh"); + }, + injectCodexConfig: async () => { + injected = true; + return { success: true, message: "must not inject" }; + }, + currentExternalCodexModelProvider: () => null, + }); + + expect(result.status).toBe("refused"); + expect(result.ok).toBe(false); + expect(result.message).toContain(privatePath); + expect(refreshed).toBe(false); + expect(injected).toBe(false); + expect(logs).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("service-home"); + expect(errors[0]).toContain("POST /api/sync"); + expect(errors[0]).not.toContain(privatePath); + }); + test("catalog sync proceeds after a bounded reasoning refresh fails on both sync paths", async () => { const calls: string[] = []; const routedConfig = {