From 7fd6808e7e5b745f54e9e5c745965fb59a0e5419 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:15:56 +0900 Subject: [PATCH 1/2] fix(catalog): carry the main account's model availability prompt onto native rows Codex shows a model availability prompt (for example a trial offer) only when a model row carries availability_nux. OpenCodex fetched each account's live ChatGPT roster but dropped that field, so native rows kept the pinned template's null and the prompt never reached the app while OpenCodex owned the catalog. The roster parser now keeps a validated { message } per model. Bare native rows take it from the confirmed main-account roster and are reset to null otherwise; account-qualified, combo and native alias rows carry none, so a Pool account's prompt never appears. Refs #4213 --- .../content/docs/guides/codex-integration.md | 1 + scripts/test-layout/layout.json | 1 + src/codex/catalog/access-programs.ts | 16 +- src/codex/model-entitlements.ts | 29 ++- structure/catalog.md | 4 +- .../codex-native-availability-nux.test.ts | 228 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 tests/codex-integration/codex-native-availability-nux.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ba4da1ac432..28c40eddcb0 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -19,6 +19,7 @@ For bare native Codex models and OpenCodex-generated account-selector rows, the authenticated access program metadata. Bare models use the main Codex account; account-qualified models use their selected account. Refreshing the integration updates these rows when upstream changes the account's access programs. +OpenCodex carries the logged-in main account's live model availability prompt onto bare native model rows. The proxy exposes one bare `openai` Codex-login route with Pool(default) and Direct account modes, plus `openai-apikey/` for the configured API key. Pool includes main plus added accounts; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4bae502f37f..305b5723e89 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -684,6 +684,7 @@ "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-entitlement-admission.test.ts": "codex-integration", "codex-model-entitlements-program-shape.test.ts": "codex-integration", + "codex-native-availability-nux.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", diff --git a/src/codex/catalog/access-programs.ts b/src/codex/catalog/access-programs.ts index 3838296725e..97887980ab3 100644 --- a/src/codex/catalog/access-programs.ts +++ b/src/codex/catalog/access-programs.ts @@ -6,7 +6,7 @@ import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { isNativeAliasCatalogEntry, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./metadata"; import type { RawEntry } from "./parsing"; -/** Project authenticated account metadata onto native rows only. */ +/** Project access programs per native account, and availability prompts only onto bare main rows. */ export function applyNativeAccessPrograms( entries: RawEntry[], snapshot: CodexModelEntitlementSnapshot, @@ -15,9 +15,13 @@ export function applyNativeAccessPrograms( for (const entry of entries) { // A combo may deliberately claim a bare native slug. It is still a routed combo row, // so the matching native Codex roster must not project its access programs onto it. - if (isNativeAliasCatalogEntry(entry)) continue; + if (isNativeAliasCatalogEntry(entry)) { + delete entry.availability_nux; + continue; + } if (entry.owned_by === COMBO_NAMESPACE) { delete entry.available_access_programs; + delete entry.availability_nux; continue; } const accountBoundSlug = trustedAccountBoundNativeCatalogSlug(entry); @@ -25,10 +29,18 @@ export function applyNativeAccessPrograms( && SUPPORTED_NATIVE_OPENAI_SLUGS.has(entry.slug) ? entry.slug : undefined; const slug = accountBoundSlug ?? bareSlug; if (!slug) continue; + if (accountBoundSlug) delete entry.availability_nux; const selector = accountBoundSlug && typeof entry.slug === "string" ? entry.slug.slice(0, entry.slug.indexOf("/")) : undefined; const target = selector === undefined ? MAIN_CODEX_ACCOUNT_ID : accountTargets.get(selector); const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + if (bareSlug) { + entry.availability_nux = accountId === MAIN_CODEX_ACCOUNT_ID + && snapshot.confirmedAccountIds.has(accountId) + && snapshot.modelsByAccount.get(accountId)?.has(slug) + ? snapshot.availabilityNuxByAccount?.get(accountId)?.get(slug) ?? null + : null; + } if (!accountId) { delete entry.available_access_programs; continue; diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index a15423864b0..3e763c3b07d 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -408,6 +408,7 @@ interface CachedAccountModels { readonly expiresAt: number; readonly models: ReadonlySet; readonly accessProgramsByModel?: ReadonlyMap; + readonly availabilityNuxByModel?: ReadonlyMap; readonly confirmed: boolean; readonly provenance?: CodexModelEntitlementProvenance; } @@ -415,6 +416,7 @@ interface CachedAccountModels { export interface CodexModelEntitlementSnapshot { readonly modelsByAccount: ReadonlyMap>; readonly accessProgramsByAccount?: ReadonlyMap>; + readonly availabilityNuxByAccount?: ReadonlyMap>; readonly clientVersionByAccount: ReadonlyMap; readonly confirmedAccountIds: ReadonlySet; readonly credentialIdentities: ReadonlyMap; @@ -637,16 +639,28 @@ async function accountCredentialSnapshot( } } -/** Keep valid roster slugs while dropping malformed program metadata; preserve explicit null separately from absence. */ -function parseAccountModels(text: string): { models: ReadonlySet; accessProgramsByModel: ReadonlyMap } | null { +/** Keep valid roster slugs while dropping malformed program and availability metadata. */ +function parseAccountModels(text: string): { + models: ReadonlySet; + accessProgramsByModel: ReadonlyMap; + availabilityNuxByModel: ReadonlyMap; +} | null { try { const payload = JSON.parse(text) as { models?: unknown }; if (!Array.isArray(payload.models)) return null; const accessProgramsByModel = new Map(); + const availabilityNuxByModel = new Map(); const models = payload.models.flatMap(entry => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; - const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown }; + const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown; availability_nux?: unknown }; if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return []; + const nux = row.availability_nux; + if (nux && typeof nux === "object" && !Array.isArray(nux)) { + const message = (nux as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) { + availabilityNuxByModel.set(row.slug, { message: message.trim().slice(0, 2_000) }); + } + } const programs = row.available_access_programs; if (programs === null) accessProgramsByModel.set(row.slug, null); else if (programs && typeof programs === "object" && !Array.isArray(programs)) { @@ -659,7 +673,7 @@ function parseAccountModels(text: string): { models: ReadonlySet; access } return [row.slug]; }); - return { models: new Set(models), accessProgramsByModel }; + return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel }; } catch { return null; } @@ -730,7 +744,7 @@ async function fetchAccountModels( // account asked under too old a client version answers with no gated rows, and treating // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again. - const { models, accessProgramsByModel } = parsed; + const { models, accessProgramsByModel, availabilityNuxByModel } = parsed; const usable = models.size > 0; if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); @@ -754,6 +768,7 @@ async function fetchAccountModels( : MODEL_ROSTER_FAILURE_TTL_MS), models, accessProgramsByModel, + availabilityNuxByModel, confirmed: true, }; } catch (error) { @@ -1196,6 +1211,10 @@ export async function resolveCodexModelEntitlements( result.confirmed && result.accessProgramsByModel ? [[credential.accountId, result.accessProgramsByModel] as const] : [] ))), + availabilityNuxByAccount: new Map(results.flatMap(({ credential, result }) => ( + result.confirmed && result.availabilityNuxByModel + ? [[credential.accountId, result.availabilityNuxByModel] as const] : [] + ))), clientVersionByAccount: new Map(results.map(({ credential, result }) => ( [credential.accountId, result.clientVersion] ))), diff --git a/structure/catalog.md b/structure/catalog.md index 0c79ecbc20c..89ddf91254b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -157,8 +157,7 @@ the id unregisters it and the next canonical write drops the row. Configured nat like `gpt-5.5` there), and a combo `nativeAlias` cannot target one because schema validation runs before registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. -Retirement is a catalog/evidence policy, not a universal request denylist. Manually supplied -model ids still follow generic routing. User-selected config and historical usage remain stored. +Retirement is a catalog/evidence policy, not a universal request denylist. Manually supplied model ids still follow generic routing. User-selected config and historical usage remain stored. Account-gated native ids use authenticated ChatGPT `/models` rosters cached per credential generation with a bounded timeout. A bare gated row requires a confirmed eligible account; a selector-qualified row requires its mapped account. Failed discovery @@ -166,6 +165,7 @@ grants neither. The same snapshot gates Pool selection, so catalog and runtime u The roster's per-model `available_access_programs` is projected separately: bare native rows use only confirmed main-account metadata, and selector-qualified rows use only their mapped account. An object requires a valid `cyber` string array; malformed values under other program keys are omitted without losing that grant. Explicit `null` stays null; omission stays omitted. Failed discovery or credential replacement removes stale access-program metadata. It changes presentation, not routing grants. +The roster's `availability_nux.message` is trimmed, capped at 2,000 characters, and projected only onto bare native rows from a confirmed main-account roster that lists the slug. Missing or unconfirmed main metadata writes `null` to clear stale prompts; account-qualified, combo, and native alias rows carry no availability prompt. Pool-account prompts never reach the bare row. `client_version` arrives on the inbound request and is part of that cache identity, so `src/codex/model-entitlements.ts` bounds the work as well as the state: stored versions per account, concurrent diff --git a/tests/codex-integration/codex-native-availability-nux.test.ts b/tests/codex-integration/codex-native-availability-nux.test.ts new file mode 100644 index 00000000000..157e785e23e --- /dev/null +++ b/tests/codex-integration/codex-native-availability-nux.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; +import { applyNativeAccessPrograms } from "../../src/codex/catalog/access-programs"; +import { CODEX_ACCOUNT_BOUND_CATALOG_KIND } from "../../src/codex/catalog/account-models"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../../src/codex/catalog/kinds"; +import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; +import { commitCodexCatalogCandidate, gatherCodexCatalogCandidate } from "../../src/codex/convergence"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { + resetCodexModelEntitlementCacheForTests, + resolveCodexModelEntitlements, + type CodexModelEntitlementCredentialSnapshot, +} from "../../src/codex/model-entitlements"; +import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { saveConfig } from "../../src/config"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const MODEL = "gpt-6-astra"; +const WRITER_MODEL = "gpt-5.6-sol"; +const CLIENT_VERSION = "0.146.0"; +setDefaultTimeout(30_000); +const mainCredential: CodexModelEntitlementCredentialSnapshot = { + accountId: MAIN_CODEX_ACCOUNT_ID, accessToken: "synthetic-main-token", + chatgptAccountId: "synthetic-main-account", credentialIdentity: "synthetic:main", +}; +const poolCredential: CodexModelEntitlementCredentialSnapshot = { + accountId: "synthetic-pool", accessToken: "synthetic-pool-token", + chatgptAccountId: "synthetic-pool-account", credentialIdentity: "synthetic:pool", +}; + +function rosterRow(slug: string, availability_nux?: unknown): Record { + return { slug, supported_in_api: true, visibility: "list", availability_nux }; +} + +async function snapshot(main: unknown[], pool: unknown[] = [], mainFails = false) { + return resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [mainCredential, poolCredential], now: 1_000, + clientVersion: CLIENT_VERSION, + fetcher: (async (_input, init) => { + const id = new Headers(init?.headers).get("chatgpt-account-id"); + if (id === mainCredential.chatgptAccountId && mainFails) return new Response(null, { status: 503 }); + return Response.json({ models: id === mainCredential.chatgptAccountId ? main : pool }); + }) as typeof fetch, + }); +} + +beforeEach(() => resetCodexModelEntitlementCacheForTests()); + +test("confirmed main roster projects only its bounded message onto a bare native row", async () => { + const result = await snapshot([rosterRow(MODEL, { message: ` ${"M".repeat(2_100)} `, extra: "ignored" })], + [rosterRow(MODEL, { message: "Pool prompt" })]); + expect(result.availabilityNuxByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(MODEL)) + .toEqual({ message: "M".repeat(2_000) }); + expect(result.availabilityNuxByAccount?.get("synthetic-pool")?.get(MODEL)) + .toEqual({ message: "Pool prompt" }); + const rows: RawEntry[] = [ + { slug: MODEL, availability_nux: { message: "Old prompt" } }, + { slug: `desktop/${MODEL}`, opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, availability_nux: { message: "Old prompt" } }, + { slug: `pool/${MODEL}`, opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, availability_nux: { message: "Old prompt" } }, + { slug: MODEL, owned_by: "combo", availability_nux: { message: "Old prompt" } }, + { slug: MODEL, opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, availability_nux: { message: "Old prompt" } }, + ]; + applyNativeAccessPrograms(rows, result, new Map([["desktop", MAIN_CODEX_ACCOUNT_ID], ["pool", "synthetic-pool"]])); + expect(rows[0]?.availability_nux).toEqual({ message: "M".repeat(2_000) }); + for (const row of rows.slice(1)) expect(row).not.toHaveProperty("availability_nux"); +}); + +test.each([undefined, null, "bad", [], {}, { message: " " }, { message: 42 }])( + "missing or malformed main NUX %p clears a stale bare prompt without changing roster confirmation", + async value => { + const result = await snapshot([{ + ...rosterRow(MODEL, value), available_access_programs: { cyber: ["standard"] }, + }]); + expect(result.confirmedAccountIds.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(result.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(MODEL)) + .toEqual({ cyber: ["standard"] }); + const row: RawEntry = { slug: MODEL, availability_nux: { message: "Old prompt" } }; + applyNativeAccessPrograms([row], result, new Map()); + expect(row.availability_nux).toBeNull(); + }, +); + +test("hidden rows and failed or unlisted main rosters cannot retain a prompt", async () => { + const hidden = await snapshot([{ ...rosterRow(MODEL, { message: "Hidden" }), visibility: "hide" }]); + expect(hidden.availabilityNuxByAccount?.get(MAIN_CODEX_ACCOUNT_ID)).toBeUndefined(); + const row: RawEntry = { slug: MODEL, availability_nux: { message: "Old prompt" } }; + applyNativeAccessPrograms([row], hidden, new Map()); + expect(row.availability_nux).toBeNull(); + resetCodexModelEntitlementCacheForTests(); + const failed = await snapshot([rosterRow(MODEL, { message: "Never seen" })], [], true); + expect(failed.confirmedAccountIds.has(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(failed.availabilityNuxByAccount?.has(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + row.availability_nux = { message: "Old prompt" }; + applyNativeAccessPrograms([row], failed, new Map()); + expect(row.availability_nux).toBeNull(); + resetCodexModelEntitlementCacheForTests(); + const unlisted = await snapshot([rosterRow("gpt-6-sol")], [rosterRow(MODEL, { message: "Pool only" })]); + row.availability_nux = { message: "Old prompt" }; + applyNativeAccessPrograms([row], unlisted, new Map()); + expect(row.availability_nux).toBeNull(); +}); + +let home: TempHome | undefined; +let previousCliPath: string | undefined; +let previousFetch: typeof fetch; +let mainPrompt: unknown; +let poolPrompt: unknown; + +function writerConfig(): OcxConfig { + return { + port: 10100, + providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" } }, + defaultProvider: "openai", + codexAccounts: [{ id: "synthetic-pool", email: "pool@example.test", alias: "Pool", isMain: false }], + codexAccountNamespaces: { desktop: "@main", team: "synthetic-pool" }, + codexAccountPickerEnabled: true, + disabledModels: [], + }; +} + +function writtenRows(): RawEntry[] { + return (JSON.parse(readFileSync(join(home!.codexHome, "opencodex-catalog.json"), "utf8")) as RawCatalog).models ?? []; +} + +function primeWriter(): void { + const runtimeScript = home!.path("codex-fixture.js"); + const bundled = JSON.stringify({ models: [{ + slug: "gpt-5.5", display_name: "GPT-5.5", description: "Fixture native", + priority: 1, visibility: "list", base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + default_reasoning_level: "medium", + }] }); + writeFileSync(runtimeScript, [ + 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', + `else process.stdout.write(${JSON.stringify(bundled)});`, + ].join("\n")); + const runtimeCommand = home!.path(process.platform === "win32" ? "codex-fixture.cmd" : "codex-fixture"); + writeFileSync(runtimeCommand, process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${runtimeScript}" %*\r\n` + : `#!/bin/sh\nexec "${process.execPath}" "${runtimeScript}" "$@"\n`); + if (process.platform !== "win32") chmodSync(runtimeCommand, 0o755); + process.env.CODEX_CLI_PATH = runtimeCommand; + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); +} + +async function writeWith(writer: "retained" | "convergence", config: OcxConfig): Promise { + saveConfig(config); + if (writer === "retained") await syncCatalogModels(config); + else { + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config)); + expect(gathered.kind).toBe("candidate"); + if (gathered.kind !== "candidate") throw new Error("expected candidate"); + expect((await commitCodexCatalogCandidate(gathered.candidate, 1_000)).kind).toBe("committed"); + } + return writtenRows(); +} + +test.each(["retained", "convergence"] as const)("%s writes and later clears main-only NUX in the on-disk catalog", async writer => { + previousCliPath = process.env.CODEX_CLI_PATH; + previousFetch = globalThis.fetch; + home = createTempHome("ocx-native-nux-"); + mkdirSync(home.codexHome); + writeFileSync(join(home.codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "synthetic-main-token", account_id: "synthetic-main-account" }, + })); + saveCodexAccountCredential("synthetic-pool", { + accessToken: "synthetic-pool-token", refreshToken: "synthetic-pool-refresh", + expiresAt: Date.now() + 300_000, chatgptAccountId: "synthetic-pool-account", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + const id = new Headers(init?.headers).get("chatgpt-account-id"); + return Response.json({ models: [rosterRow(WRITER_MODEL, + id === "synthetic-main-account" ? mainPrompt : poolPrompt)] }); + } + return previousFetch(input, init); + }) as typeof fetch; + try { + mainPrompt = { message: "Main trial" }; + poolPrompt = { message: "Pool trial" }; + primeWriter(); + writeFileSync(join(home.codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: WRITER_MODEL, display_name: "Sol", visibility: "list", priority: 1, + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + availability_nux: { message: "Old prompt" } }], + })); + const config = writerConfig(); + let rows = await writeWith(writer, config); + expect(rows.find(row => row.slug === WRITER_MODEL)?.availability_nux).toEqual({ message: "Main trial" }); + expect(JSON.stringify(rows)).not.toContain("Pool trial"); + for (const slug of [`desktop/${WRITER_MODEL}`, `team/${WRITER_MODEL}`]) { + expect(rows.find(row => row.slug === slug)).not.toHaveProperty("availability_nux"); + } + mainPrompt = undefined; + resetCodexModelEntitlementCacheForTests(); + rows = await writeWith(writer, config); + expect(rows.find(row => row.slug === WRITER_MODEL)?.availability_nux).toBeNull(); + for (const slug of [`desktop/${WRITER_MODEL}`, `team/${WRITER_MODEL}`]) { + expect(rows.find(row => row.slug === slug)).not.toHaveProperty("availability_nux"); + } + config.combos = { trial: { + alias: WRITER_MODEL, nativeAlias: true, displayName: "Synthetic alias", + targets: [{ provider: "openai", model: WRITER_MODEL }], + } }; + rows = await writeWith(writer, config); + const alias = rows.find(row => row.slug === WRITER_MODEL); + expect(alias?.opencodex_catalog_kind).toBe(CODEX_NATIVE_ALIAS_CATALOG_KIND); + expect(alias).not.toHaveProperty("availability_nux"); + } finally { + globalThis.fetch = previousFetch; + if (previousCliPath === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCliPath; + resetCodexRuntimeResolveCacheForTests(); + home.remove(); + home = undefined; + } +}); + +afterEach(() => resetCodexModelEntitlementCacheForTests()); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4fe30686d8b..980bf06d6ec 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -528,6 +528,7 @@ "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-entitlement-admission.test.ts": "codex-integration", "codex-model-entitlements-program-shape.test.ts": "codex-integration", + "codex-native-availability-nux.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", From f26e963e0ef9cfb7ecd904d2c1b241c77cabb662 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:55:57 +0900 Subject: [PATCH 2/2] fix(catalog): never split a surrogate pair when capping the availability prompt Codex reads the catalog with a strict JSON parser that rejects a lone surrogate escape, so one emoji at the 2,000-unit cut could make the whole catalog unreadable. Lone surrogates, at the cut or from upstream, are now dropped. --- src/codex/model-entitlements.ts | 14 +++++++++++--- structure/catalog.md | 2 +- .../codex-native-availability-nux.test.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 3e763c3b07d..26dc998ac94 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -639,6 +639,15 @@ async function accountCredentialSnapshot( } } +const AVAILABILITY_MESSAGE_MAX_CODE_UNITS = 2_000; +// Codex parses the catalog with a strict JSON reader that rejects a lone surrogate escape. +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; @@ -657,9 +666,8 @@ function parseAccountModels(text: string): { const nux = row.availability_nux; if (nux && typeof nux === "object" && !Array.isArray(nux)) { const message = (nux as { message?: unknown }).message; - if (typeof message === "string" && message.trim()) { - availabilityNuxByModel.set(row.slug, { message: message.trim().slice(0, 2_000) }); - } + const bounded = typeof message === "string" ? boundedAvailabilityMessage(message) : ""; + if (bounded) availabilityNuxByModel.set(row.slug, { message: bounded }); } const programs = row.available_access_programs; if (programs === null) accessProgramsByModel.set(row.slug, null); diff --git a/structure/catalog.md b/structure/catalog.md index 89ddf91254b..5beb521e4df 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -165,7 +165,7 @@ grants neither. The same snapshot gates Pool selection, so catalog and runtime u The roster's per-model `available_access_programs` is projected separately: bare native rows use only confirmed main-account metadata, and selector-qualified rows use only their mapped account. An object requires a valid `cyber` string array; malformed values under other program keys are omitted without losing that grant. Explicit `null` stays null; omission stays omitted. Failed discovery or credential replacement removes stale access-program metadata. It changes presentation, not routing grants. -The roster's `availability_nux.message` is trimmed, capped at 2,000 characters, and projected only onto bare native rows from a confirmed main-account roster that lists the slug. Missing or unconfirmed main metadata writes `null` to clear stale prompts; account-qualified, combo, and native alias rows carry no availability prompt. Pool-account prompts never reach the bare row. +The roster's `availability_nux.message` is trimmed, capped at 2,000 UTF-16 units without leaving a lone surrogate, and projected only onto bare native rows from a confirmed main-account roster that lists the slug. Missing or unconfirmed main metadata writes `null` to clear stale prompts; account-qualified, combo, and native alias rows carry no availability prompt. Pool-account prompts never reach the bare row. `client_version` arrives on the inbound request and is part of that cache identity, so `src/codex/model-entitlements.ts` bounds the work as well as the state: stored versions per account, concurrent diff --git a/tests/codex-integration/codex-native-availability-nux.test.ts b/tests/codex-integration/codex-native-availability-nux.test.ts index 157e785e23e..016ad8b32e0 100644 --- a/tests/codex-integration/codex-native-availability-nux.test.ts +++ b/tests/codex-integration/codex-native-availability-nux.test.ts @@ -86,6 +86,21 @@ test.each([undefined, null, "bad", [], {}, { message: " " }, { message: 42 }])( }, ); +test("the cap never splits a surrogate pair and lone surrogates are dropped", async () => { + // 1,999 ASCII units then an emoji: the 2,000-unit cut lands between its two halves. + const straddling = `${"a".repeat(1_999)}\u{1F600}tail`; + const cut = await snapshot([rosterRow(MODEL, { message: straddling })]); + const cutMessage = cut.availabilityNuxByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(MODEL)?.message ?? ""; + expect(cutMessage).toBe("a".repeat(1_999)); + expect(cutMessage.isWellFormed()).toBe(true); + resetCodexModelEntitlementCacheForTests(); + const lone = await snapshot([rosterRow(MODEL, { message: "Try \uD83D it \u{1F600}" })]); + const loneMessage = lone.availabilityNuxByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(MODEL)?.message ?? ""; + expect(loneMessage).toBe("Try it \u{1F600}"); + expect(loneMessage.isWellFormed()).toBe(true); + expect(JSON.stringify({ message: loneMessage })).not.toMatch(/\\ud[89ab]/i); +}); + test("hidden rows and failed or unlisted main rosters cannot retain a prompt", async () => { const hidden = await snapshot([{ ...rosterRow(MODEL, { message: "Hidden" }), visibility: "hide" }]); expect(hidden.availabilityNuxByAccount?.get(MAIN_CODEX_ACCOUNT_ID)).toBeUndefined();