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..26dc998ac94 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,36 @@ 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 { +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])|(?; + 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; + 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); else if (programs && typeof programs === "object" && !Array.isArray(programs)) { @@ -659,7 +681,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 +752,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 +776,7 @@ async function fetchAccountModels( : MODEL_ROSTER_FAILURE_TTL_MS), models, accessProgramsByModel, + availabilityNuxByModel, confirmed: true, }; } catch (error) { @@ -1196,6 +1219,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..5beb521e4df 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 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 new file mode 100644 index 00000000000..016ad8b32e0 --- /dev/null +++ b/tests/codex-integration/codex-native-availability-nux.test.ts @@ -0,0 +1,243 @@ +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("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(); + 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",