@bitkyc08/opencodex 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/gui/dist/assets/index-Co12XTT-.js +76 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/cursor/discovery.ts +4 -1
  5. package/src/adapters/cursor/effort-map.ts +5 -1
  6. package/src/adapters/cursor/request-builder.ts +3 -3
  7. package/src/adapters/google.ts +25 -5
  8. package/src/adapters/openai-chat.ts +182 -6
  9. package/src/adapters/openai-responses.ts +17 -7
  10. package/src/codex/catalog/bundled.ts +16 -0
  11. package/src/codex/catalog/metadata.ts +180 -5
  12. package/src/codex/catalog/parsing.ts +7 -6
  13. package/src/codex/catalog/sync.ts +73 -6
  14. package/src/codex/catalog.ts +1 -1
  15. package/src/codex/convergence.ts +20 -0
  16. package/src/codex/prompt-journal.ts +50 -13
  17. package/src/codex/prompt-layers.ts +1 -1
  18. package/src/config.ts +57 -0
  19. package/src/generated/compatibility-version.json +64 -48
  20. package/src/generated/model-metadata.ts +3 -3
  21. package/src/lib/local-provider-reload-contract.ts +100 -0
  22. package/src/oauth/login-cli.ts +52 -26
  23. package/src/providers/derive.ts +2 -0
  24. package/src/providers/openai-sidecar.ts +9 -2
  25. package/src/providers/quota.ts +57 -0
  26. package/src/providers/registry.ts +53 -25
  27. package/src/responses/state.ts +22 -0
  28. package/src/router.ts +1 -0
  29. package/src/server/claude-messages.ts +57 -11
  30. package/src/server/direct-local-http.ts +7 -3
  31. package/src/server/images.ts +6 -0
  32. package/src/server/index.ts +51 -11
  33. package/src/server/live.ts +117 -13
  34. package/src/server/local-provider-reload-client.ts +137 -0
  35. package/src/server/management/config-routes.ts +20 -3
  36. package/src/server/management/logs-usage-routes.ts +28 -0
  37. package/src/server/management/model-routes.ts +11 -3
  38. package/src/server/management/model-rows.ts +18 -3
  39. package/src/server/management/provider-routes.ts +107 -3
  40. package/src/server/management-auth.ts +65 -1
  41. package/src/server/proxy-liveness.ts +1 -0
  42. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  43. package/src/server/responses/agent-task-recovery.ts +460 -0
  44. package/src/server/responses/compact.ts +4 -2
  45. package/src/server/responses/core.ts +142 -6
  46. package/src/server/responses/encrypted-payload.ts +4 -1
  47. package/src/server/search.ts +4 -0
  48. package/src/types.ts +27 -0
  49. package/src/usage/expected-prices.ts +11 -0
  50. package/src/vision/describe.ts +4 -0
  51. package/src/web-search/anthropic-executor.ts +5 -1
  52. package/src/web-search/executor.ts +9 -1
  53. package/src/web-search/index.ts +5 -0
  54. package/src/web-search/loop.ts +42 -5
  55. package/gui/dist/assets/index-BHldBl6_.js +0 -76
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
5
  import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
7
8
  import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
9
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
10
  import type { OcxConfig, OcxProviderConfig } from "../../types";
@@ -33,8 +34,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
33
34
 
34
35
 
35
36
  import type { RawEntry } from "./parsing";
36
- import { readCurrentCatalogOrCache, unique } from "./bundled";
37
- import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
37
+ import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled";
38
+ import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
38
39
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
39
40
  import { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models";
40
41
  export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
@@ -194,10 +195,35 @@ export function shouldIncludeNativeOpenAi(config: Pick<OcxConfig, "providers">):
194
195
  return !hasEnabledProvider || shouldIncludeAccountBoundNativeOpenAi(config);
195
196
  }
196
197
 
198
+ type AccountSelectorConfig = Pick<
199
+ OcxConfig,
200
+ "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled"
201
+ >;
202
+
203
+ function mainAccountSelectors(config: AccountSelectorConfig): string[] {
204
+ const targets = new Map(codexAccountNamespaceEntries(config));
205
+ return visibleCodexAccountSelectors(config).filter(selector =>
206
+ isMainCodexAccountTarget(targets.get(selector) ?? ""));
207
+ }
208
+
197
209
  /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */
198
- export function desktopVisibleNativeSlugs(config: Pick<OcxConfig, "claudeCode" | "disabledModels" | "combos">): string[] {
210
+ export function desktopVisibleNativeSlugs(
211
+ config: Pick<OcxConfig, "claudeCode" | "disabledModels" | "combos" | "providers"
212
+ | "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled">,
213
+ ): string[] {
199
214
  if (config.claudeCode?.desktopNativeModels === false) return [];
200
- return visibleNativeSlugs(config);
215
+ const visible = visibleNativeSlugs(config);
216
+ if (!shouldIncludeAccountBoundNativeOpenAi(config)) return visible;
217
+ const qualified = [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) =>
218
+ slugs
219
+ .filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug))
220
+ .map(slug => `${selector}/${slug}`),
221
+ );
222
+ const disabled = new Set(config.disabledModels ?? []);
223
+ return unique([
224
+ ...visible,
225
+ ...qualified.filter(slug => !disabled.has(slug) && !disabled.has(slug.slice(slug.indexOf("/") + 1))),
226
+ ]);
201
227
  }
202
228
 
203
229
  export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
@@ -214,6 +240,7 @@ export function applyNativeVisibility(
214
240
  entries: RawEntry[],
215
241
  disabledModels: ReadonlySet<string>,
216
242
  hideBareNative = false,
243
+ observedNativeSlugs: ReadonlySet<string> = new Set(),
217
244
  ): RawEntry[] {
218
245
  for (const entry of entries) {
219
246
  if (isNativeAliasCatalogEntry(entry)) continue;
@@ -222,7 +249,7 @@ export function applyNativeVisibility(
222
249
  const nativeSlug = accountBoundSlug ?? slug;
223
250
  if (!nativeSlug
224
251
  || (!accountBoundSlug && slug.includes("/"))
225
- || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue;
252
+ || (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) && !observedNativeSlugs.has(nativeSlug))) continue;
226
253
  const disabled = disabledModels.has(nativeSlug)
227
254
  || (accountBoundSlug !== undefined && disabledModels.has(slug));
228
255
  entry.visibility = disabled || (!accountBoundSlug && hideBareNative)
@@ -259,6 +286,154 @@ export function nativeOpenAiSlugs(): string[] {
259
286
  return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
260
287
  }
261
288
 
289
+ const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/;
290
+ const ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER = "opencodex_account_observed_native";
291
+ const ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER = "opencodex_account_observed_selectors";
292
+
293
+ function isAccountBoundOpenAiNativeSlug(slug: string): boolean {
294
+ return !slug.includes("/") && ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX.test(slug);
295
+ }
296
+
297
+ /**
298
+ * Shape/plausibility filter for a candidate account-native row. **This is not a trust control.**
299
+ *
300
+ * It checks that a row carries the field shape a real Codex catalog row has, which rejects
301
+ * malformed and minimal hand-written rows. It cannot distinguish a genuine upstream observation
302
+ * from a complete row typed by hand into `$CODEX_HOME/models_cache.json`: there is no signature,
303
+ * source identity, or server attestation to check. A full-shape forged row is accepted, and
304
+ * `observedFullShapeRowIsAccepted` in tests/native-model-toggle.test.ts pins that so nobody
305
+ * later mistakes this predicate for a security boundary.
306
+ *
307
+ * That is acceptable here because the file is user-owned and written by Codex itself: anyone
308
+ * able to rewrite it can already edit `config.json` or run `ocx` directly, and `router.ts`
309
+ * accepts any bare `gpt-*` id under an account namespace regardless of this catalog. What the
310
+ * filter buys is that garbage rows do not get advertised through discovery — not that an
311
+ * advertised row is proven genuine.
312
+ */
313
+ function hasNativeCatalogRowShape(entry: RawEntry): boolean {
314
+ const levels = entry.supported_reasoning_levels;
315
+ const messages = entry.model_messages;
316
+ return typeof entry.base_instructions === "string"
317
+ && entry.base_instructions.length > 0
318
+ && (typeof entry.comp_hash === "string" || entry.comp_hash === null)
319
+ && entry.shell_type === "shell_command"
320
+ && Array.isArray(levels)
321
+ && levels.length > 0
322
+ && levels.every(level => typeof level === "object" && level !== null
323
+ && typeof (level as { effort?: unknown }).effort === "string")
324
+ && typeof messages === "object"
325
+ && messages !== null
326
+ && !Array.isArray(messages);
327
+ }
328
+
329
+ function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined {
330
+ const accountBound = trustedAccountBoundNativeCatalogSlug(entry);
331
+ const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : "");
332
+ if (!isAccountBoundOpenAiNativeSlug(slug)
333
+ || entry.supported_in_api !== true
334
+ || !hasNativeCatalogRowShape(entry)
335
+ || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) {
336
+ return undefined;
337
+ }
338
+ return slug;
339
+ }
340
+
341
+ /**
342
+ * Return exact, previously observed account-native rows that are not in the static release set.
343
+ * The result is used only to carry a hidden observation across startup cache invalidation.
344
+ */
345
+ export function observedAccountBoundNativeEntries(
346
+ observedEntries: readonly RawEntry[],
347
+ ): RawEntry[] {
348
+ const seen = new Set<string>();
349
+ return observedEntries.flatMap(entry => {
350
+ const slug = observedAccountBoundNativeSlug(entry);
351
+ // Only carry bare upstream observations across cache replacement. Account-qualified rows are
352
+ // already a projection of the current selector map and must not preserve private/stale labels.
353
+ if (!slug
354
+ || typeof entry.slug !== "string"
355
+ || entry.slug.includes("/")
356
+ || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
357
+ || seen.has(slug)) return [];
358
+ seen.add(slug);
359
+ return [structuredClone(entry)];
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Native ids observed in the user's Codex catalog/cache for account-qualified discovery.
365
+ *
366
+ * Unknown ids are deliberately returned only to callers that build selector-qualified rows. The
367
+ * static bare set remains the source of truth for global/API-key discovery, while this preserves
368
+ * exact account-scoped ids such as `gpt-daybreak-blue-latest` until the static set catches up.
369
+ */
370
+ export function accountBoundNativeOpenAiSlugs(
371
+ observedEntries: readonly RawEntry[] = [
372
+ ...(readCurrentCodexModelsCache()?.models ?? []),
373
+ // Existing generated rows are also safe to reuse after a process starts without a cache
374
+ // invalidation pass; bare user-authored catalog rows are intentionally not trusted here.
375
+ ...(readCurrentCodexCatalog()?.models ?? []).filter(entry =>
376
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
377
+ ],
378
+ ): string[] {
379
+ const observed = observedEntries.flatMap(entry => {
380
+ const slug = observedAccountBoundNativeSlug(entry);
381
+ return slug === undefined ? [] : [slug];
382
+ });
383
+ return unique([...NATIVE_OPENAI_MODELS, ...observed]);
384
+ }
385
+
386
+ /**
387
+ * Resolve account-native ids per public selector. Bare observations come from Codex's main
388
+ * catalog/cache, so they are eligible only for selectors that target the main account. A
389
+ * generated qualified row carries its own selector and never gets copied to an unrelated pool
390
+ * account. An explicit observation marker is public selector metadata only; private account ids
391
+ * never enter the catalog or cache.
392
+ */
393
+ export function accountBoundNativeOpenAiSlugsBySelector(
394
+ config: AccountSelectorConfig,
395
+ observedEntries: readonly RawEntry[] = [
396
+ ...(readCurrentCodexModelsCache()?.models ?? []),
397
+ ...(readCurrentCodexCatalog()?.models ?? []).filter(entry =>
398
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
399
+ ],
400
+ ): ReadonlyMap<string, readonly string[]> {
401
+ const selectors = visibleCodexAccountSelectors(config);
402
+ const mainSelectors = new Set(mainAccountSelectors(config));
403
+ const result = new Map<string, Set<string>>(
404
+ selectors.map(selector => [selector, new Set(NATIVE_OPENAI_MODELS)]),
405
+ );
406
+ for (const entry of observedEntries) {
407
+ const slug = observedAccountBoundNativeSlug(entry);
408
+ if (slug === undefined || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue;
409
+ const generated = trustedAccountBoundNativeCatalogSlug(entry);
410
+ const generatedSelector = generated === undefined || typeof entry.slug !== "string"
411
+ ? undefined
412
+ : entry.slug.slice(0, entry.slug.indexOf("/"));
413
+ const markedSelectors = Array.isArray(entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER])
414
+ ? entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER].filter((value): value is string => typeof value === "string")
415
+ : [];
416
+ const eligible = generatedSelector !== undefined
417
+ ? (mainSelectors.has(generatedSelector) ? [generatedSelector] : [])
418
+ : markedSelectors.length > 0
419
+ ? markedSelectors.filter(selector => mainSelectors.has(selector))
420
+ : [...mainSelectors];
421
+ for (const selector of eligible) {
422
+ const rows = result.get(selector);
423
+ if (rows) rows.add(slug);
424
+ }
425
+ }
426
+ return new Map([...result.entries()].map(([selector, slugs]) => [selector, [...slugs]]));
427
+ }
428
+
429
+ /** Unknown native ids observed from Codex, excluding the static release set. */
430
+ export function observedAccountBoundNativeOpenAiSlugs(
431
+ observedEntries?: readonly RawEntry[],
432
+ ): string[] {
433
+ const all = accountBoundNativeOpenAiSlugs(observedEntries);
434
+ return all.filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug));
435
+ }
436
+
262
437
  function catalogNativeSlugs(): string[] {
263
438
  const cat = readCurrentCatalogOrCache();
264
439
  const models = cat?.models ?? [];
@@ -393,17 +393,18 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
393
393
  // Per-model routed opt-ins can be added once provider metadata exposes this capability.
394
394
  delete entry.supports_reasoning_summaries;
395
395
  const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
396
- // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
397
- // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
398
- // models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
399
- // rejects server search queries advertising the tool would make models call into a void.
396
+ // `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted
397
+ // web-search capability. OpenCodex can round-trip tool_search when a client sends it, but routed
398
+ // providers have no provider/model proof that Codex App plugins work through that deferred
399
+ // surface. Advertising it unconditionally hides the App's compatible direct MCP tools (#1522),
400
+ // so routed rows fail closed to direct discovery. The sidecar-backed hosted web-search metadata
401
+ // remains advertised independently for non-Cursor routes.
400
402
  if (isCursorEntry) {
401
403
  delete entry.web_search_tool_type;
402
- entry.supports_search_tool = false;
403
404
  } else {
404
405
  entry.web_search_tool_type = "text_and_image";
405
- entry.supports_search_tool = true;
406
406
  }
407
+ entry.supports_search_tool = false;
407
408
  // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
408
409
  // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
409
410
  // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
@@ -30,11 +30,12 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
30
  import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
  import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
33
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
33
34
 
34
35
 
35
36
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
36
37
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
37
- import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
38
+ import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
38
39
  import {
39
40
  bundledCatalogCacheState,
40
41
  loadBundledCodexCatalog,
@@ -313,7 +314,7 @@ export function deriveEntry(
313
314
  slug, display_name: routedDisplayName(slug), description: desc,
314
315
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
315
316
  priority, base_instructions: "You are a helpful coding assistant.",
316
- ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
317
+ ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: false } : {}),
317
318
  };
318
319
  if (isRouted) {
319
320
  applyRoutedCodexToolMode(entry);
@@ -346,6 +347,10 @@ export interface ObservedCatalogEntryBuildInput {
346
347
  readonly disabledNativeAccountSlugs: ReadonlySet<string>;
347
348
  readonly multiAgentV2Enabled: boolean;
348
349
  readonly openaiContextCap?: number;
350
+ /** Additional native ids to clone under account selectors, without creating bare rows. */
351
+ readonly accountNativeSlugs?: readonly string[];
352
+ /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */
353
+ readonly accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>;
349
354
  }
350
355
 
351
356
  /** Build entries with the process-observed Codex feature state. */
@@ -361,6 +366,8 @@ export function buildCatalogEntries(
361
366
  suppressedBareNativeSlugs: ReadonlySet<string> = new Set(),
362
367
  disabledNativeAccountSlugs: ReadonlySet<string> = new Set(),
363
368
  contextCap?: number,
369
+ accountNativeSlugs?: readonly string[],
370
+ accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>,
364
371
  ): RawEntry[] {
365
372
  return buildCatalogEntriesFromObservedState({
366
373
  template,
@@ -375,6 +382,8 @@ export function buildCatalogEntries(
375
382
  disabledNativeAccountSlugs,
376
383
  multiAgentV2Enabled: isMultiAgentV2Enabled(),
377
384
  openaiContextCap: contextCap,
385
+ accountNativeSlugs,
386
+ accountNativeSlugsBySelector,
378
387
  });
379
388
  }
380
389
 
@@ -392,6 +401,8 @@ export function buildCatalogEntriesFromObservedState({
392
401
  disabledNativeAccountSlugs,
393
402
  multiAgentV2Enabled,
394
403
  openaiContextCap,
404
+ accountNativeSlugs,
405
+ accountNativeSlugsBySelector,
395
406
  }: ObservedCatalogEntryBuildInput): RawEntry[] {
396
407
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
397
408
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
@@ -450,8 +461,16 @@ export function buildCatalogEntriesFromObservedState({
450
461
  emittedNativeAliases.add(nativeAlias);
451
462
  emittedNativeAliasSlugs.add(slug);
452
463
  }
464
+ const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const));
453
465
  for (const [selectorIndex, selector] of accountSelectors.entries()) {
454
- for (const [nativeIndex, native] of nativeEntries.entries()) {
466
+ const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector)
467
+ ?? accountNativeSlugs
468
+ ?? gptSlugs;
469
+ const accountNativeEntries = selectorNativeSlugs.map(slug => (
470
+ nativeEntriesBySlug.get(slug)
471
+ ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap)
472
+ ));
473
+ for (const [nativeIndex, native] of accountNativeEntries.entries()) {
455
474
  const nativeSlug = String(native.slug);
456
475
  if (disabledNativeAccountSlugs.has(nativeSlug)) continue;
457
476
  const e = JSON.parse(JSON.stringify(native)) as RawEntry;
@@ -785,6 +804,7 @@ export function mergeCatalogEntriesFromObservedState({
785
804
  && !(m.slug as string).includes("/")
786
805
  && m.owned_by !== COMBO_NAMESPACE
787
806
  && (policy.unsupportedNativeEntries === "preserve"
807
+ || policy.nativeBackfillSlugs.includes(m.slug as string)
788
808
  || !isUnsupportedOpenAiNativeSlug(m.slug as string)))
789
809
  .map(m => {
790
810
  const slug = m.slug as string;
@@ -936,6 +956,11 @@ export function mergeCatalogEntriesFromObservedState({
936
956
  }
937
957
 
938
958
  const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries];
959
+ const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => {
960
+ const slug = trustedAccountBoundNativeCatalogSlug(entry);
961
+ return slug === undefined ? [] : [slug];
962
+ }));
963
+ for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug);
939
964
  const mergedEntries = [...native, ...managedEntries].map(m => {
940
965
  const normalized = normalizeServiceTiers(m);
941
966
  if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
@@ -969,7 +994,7 @@ export function mergeCatalogEntriesFromObservedState({
969
994
  // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable
970
995
  // only their generated account row.
971
996
  const versionedEntries = applyMultiAgentMode(
972
- applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0),
997
+ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs),
973
998
  multiAgentMode,
974
999
  multiAgentV2Enabled,
975
1000
  );
@@ -1055,6 +1080,7 @@ interface RetainedCatalogSyncRead {
1055
1080
  readonly catalogPath: string;
1056
1081
  readonly catalog: RawCatalog;
1057
1082
  readonly onDiskCatalog: RawCatalog | null;
1083
+ readonly modelsCache: RawCatalog | null;
1058
1084
  readonly evidence: string;
1059
1085
  /**
1060
1086
  * Process-local epochs, baselined AFTER our own gather rather than with the
@@ -1163,9 +1189,10 @@ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | n
1163
1189
  // merge source. Preservation must inspect the file that this sync is about to overwrite;
1164
1190
  // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
1165
1191
  const onDiskCatalog = readCatalog(catalogPath);
1192
+ const modelsCache = readCatalog(activeCodexModelsCachePath());
1166
1193
  const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
1167
1194
  // `processEvidence` is filled in after the provider await, not here.
1168
- return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" };
1195
+ return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" };
1169
1196
  }
1170
1197
 
1171
1198
  function revalidateRetainedCatalogSync(
@@ -1181,6 +1208,7 @@ function revalidateRetainedCatalogSync(
1181
1208
  catalogPath,
1182
1209
  catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
1183
1210
  onDiskCatalog: readCatalog(catalogPath),
1211
+ modelsCache: readCatalog(activeCodexModelsCachePath()),
1184
1212
  evidence,
1185
1213
  processEvidence: prepared.processEvidence,
1186
1214
  };
@@ -1283,6 +1311,20 @@ function writeRetainedCatalogSync({
1283
1311
  const accountSelectors = includeAccountBoundNativeOpenAi
1284
1312
  ? visibleCodexAccountSelectors(config)
1285
1313
  : [];
1314
+ const observedAccountNativeEntries = [
1315
+ ...(read.modelsCache?.models ?? []),
1316
+ ...(onDiskCatalog?.models ?? []).filter(entry =>
1317
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
1318
+ ];
1319
+ const accountNativeSlugs = accountSelectors.length > 0
1320
+ ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
1321
+ : [];
1322
+ const accountNativeSlugsBySelector = accountSelectors.length > 0
1323
+ ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
1324
+ : new Map<string, readonly string[]>();
1325
+ // Unknown account-native ids have no safe bare/global identity. They are only projected through
1326
+ // the selector map above; the no-selector catalog remains the static native/API-key surface.
1327
+ const observedNativeSlugs: string[] = [];
1286
1328
  const wsEnabled = websocketsEnabled(config);
1287
1329
  const multiAgentV2Enabled = isMultiAgentV2Enabled();
1288
1330
  const goEntries = buildCatalogEntriesFromObservedState({
@@ -1343,6 +1385,8 @@ function writeRetainedCatalogSync({
1343
1385
  disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
1344
1386
  multiAgentV2Enabled,
1345
1387
  openaiContextCap,
1388
+ accountNativeSlugs,
1389
+ accountNativeSlugsBySelector,
1346
1390
  }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
1347
1391
  : [];
1348
1392
  catalog.models = mergeCatalogEntriesFromObservedState({
@@ -1368,6 +1412,7 @@ function writeRetainedCatalogSync({
1368
1412
  openaiContextCap,
1369
1413
  policy: {
1370
1414
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1415
+ nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
1371
1416
  warningPolicy: "emit",
1372
1417
  },
1373
1418
  });
@@ -1591,10 +1636,32 @@ export function invalidateCodexModelsCacheWithPermit(
1591
1636
  if (!existsSync(catalogPath)) return false;
1592
1637
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
1593
1638
  const models = catalog.models ?? catalog;
1639
+ const currentCache = readCatalog(activeCodexModelsCachePath());
1640
+ const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
1641
+ typeof entry.slug === "string" ? [entry.slug] : []));
1642
+ const currentConfig = loadConfig();
1643
+ const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => {
1644
+ const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector);
1645
+ return isMainCodexAccountTarget(target ?? "");
1646
+ });
1647
+ const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? [])
1648
+ .filter(entry => {
1649
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1650
+ return !existingSlugs.has(slug);
1651
+ })
1652
+ .map(entry => ({
1653
+ ...entry,
1654
+ // Keep the observation in Codex's cache without advertising a new bare picker row. The
1655
+ // next OpenCodex catalog sync consumes this marker and creates only selector-qualified
1656
+ // rows for the currently configured public account selectors.
1657
+ visibility: "hide",
1658
+ opencodex_account_observed_native: true,
1659
+ opencodex_account_observed_selectors: mainSelectors,
1660
+ }));
1594
1661
  const wrapper = {
1595
1662
  fetched_at: "2000-01-01T00:00:00Z",
1596
1663
  client_version: "0.0.0",
1597
- models,
1664
+ models: [...models, ...observedAccountModels],
1598
1665
  };
1599
1666
  replaceCodexModelsCache(permit, owningCodexHome, {
1600
1667
  path: activeCodexModelsCachePath(),
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
5
+ export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
@@ -49,6 +49,8 @@ import {
49
49
  import { exactComboCatalogSlugs } from "./catalog/aggregation";
50
50
  import {
51
51
  isNativeAliasCatalogEntry,
52
+ accountBoundNativeOpenAiSlugs,
53
+ accountBoundNativeOpenAiSlugsBySelector,
52
54
  disabledNativeSlugs,
53
55
  desktopAllowlistSuppressedNativeSlugs,
54
56
  NATIVE_OPENAI_MODELS,
@@ -216,6 +218,7 @@ function prepareCatalog(
216
218
  baselineCatalogModels: readonly Readonly<Record<string, unknown>>[],
217
219
  degradedProviderNames: ReadonlySet<string>,
218
220
  nativeRecoverySources: readonly (readonly RawEntry[])[] = [],
221
+ observedAccountNativeEntries: readonly RawEntry[] = [],
219
222
  ): RawCatalog {
220
223
  const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
221
224
  const template = findNativeTemplate(catalog);
@@ -232,6 +235,15 @@ function prepareCatalog(
232
235
  const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config)
233
236
  ? visibleCodexAccountSelectors(config)
234
237
  : [];
238
+ const accountNativeSlugs = accountSelectors.length > 0
239
+ ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
240
+ : [];
241
+ const accountNativeSlugsBySelector = accountSelectors.length > 0
242
+ ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
243
+ : new Map<string, readonly string[]>();
244
+ // Unknown account-native ids have no safe bare/global identity. They are only projected through
245
+ // selector-qualified rows when a live selector is configured.
246
+ const observedNativeSlugs: string[] = [];
235
247
  const disabledNative = disabledNativeSlugs(config);
236
248
  const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery(
237
249
  active?.models ?? catalog.models ?? [],
@@ -265,6 +277,8 @@ function prepareCatalog(
265
277
  suppressedBareNativeSlugs,
266
278
  disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))),
267
279
  multiAgentV2Enabled,
280
+ accountNativeSlugs,
281
+ accountNativeSlugsBySelector,
268
282
  }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined);
269
283
  const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name));
270
284
  const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
@@ -296,6 +310,7 @@ function prepareCatalog(
296
310
  suppressedBareNativeSlugs,
297
311
  policy: {
298
312
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
313
+ nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
299
314
  warningPolicy: "suppress",
300
315
  },
301
316
  });
@@ -384,6 +399,11 @@ export async function gatherCodexCatalogCandidate(
384
399
  catalogFrom(keyedBackupBytes)?.models ?? [],
385
400
  catalogFrom(legacyBackupBytes)?.models ?? [],
386
401
  ],
402
+ [
403
+ ...(catalogFrom(cacheBytes)?.models ?? []),
404
+ ...(catalogFrom(activeBytes)?.models ?? []).filter(entry =>
405
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
406
+ ],
387
407
  );
388
408
  const preparedCatalogBytes = catalogBytes(preparedCatalog);
389
409
  const preparedCacheBytes = `${JSON.stringify({
@@ -20,7 +20,7 @@
20
20
  * recovery sees a mismatch and overwrites their work with a stale image.
21
21
  */
22
22
  import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
23
- import { dirname } from "node:path";
23
+ import { dirname, resolve } from "node:path";
24
24
  import { createHash, randomBytes } from "node:crypto";
25
25
  import { forgetEphemeralSecretPath, hardenSecretPath, windowsSecretAclApplies } from "../lib/windows-secret-acl";
26
26
 
@@ -210,14 +210,40 @@ export type RecoveryOutcome =
210
210
  | { ok: true; action: "none" | "committed" | "rolled-back" }
211
211
  | { ok: false; error: "recovery_required"; detail: string };
212
212
 
213
+ export interface RecoveryTargets {
214
+ configPath: string;
215
+ storePath: string;
216
+ }
217
+
218
+ /** Lexical path identity only: never resolve an untrusted journal path through the filesystem. */
219
+ export function sameRecoveryPath(
220
+ recordedPath: string,
221
+ expectedPath: string,
222
+ platform: NodeJS.Platform = process.platform,
223
+ ): boolean {
224
+ const recorded = resolve(recordedPath);
225
+ const expected = resolve(expectedPath);
226
+ return platform === "win32"
227
+ ? recorded.toLowerCase() === expected.toLowerCase()
228
+ : recorded === expected;
229
+ }
230
+
213
231
  /**
214
232
  * Run at service start and at every lock acquisition. Never from a read.
215
233
  *
216
234
  * A journal on disk means commit never happened, so the default is rollback.
217
235
  * The single exception is not a roll-forward: when BOTH targets already hold the
218
236
  * post-image the writes had finished and only the journal deletion was missing.
237
+ *
238
+ * The expected targets come from the active prompt-layer configuration, not
239
+ * from the journal. The envelope checksum detects accidental corruption, not a
240
+ * same-user forgery, so recovery binds the decoded record to those trusted
241
+ * paths before reading either target.
219
242
  */
220
- export function recoverIfNeeded(journalPath: string): RecoveryOutcome {
243
+ export function recoverIfNeeded(
244
+ journalPath: string,
245
+ expectedTargets: RecoveryTargets,
246
+ ): RecoveryOutcome {
221
247
  const raw = readOrNull(journalPath);
222
248
  if (raw === null) return { ok: true, action: "none" };
223
249
 
@@ -230,11 +256,22 @@ export function recoverIfNeeded(journalPath: string): RecoveryOutcome {
230
256
  };
231
257
  }
232
258
 
233
- const config = classify(readOrNull(record.configPath), record.preConfig, record.postConfig);
234
- const store = classify(readOrNull(record.storePath), record.preStore, record.postStore);
259
+ if (
260
+ !sameRecoveryPath(record.configPath, expectedTargets.configPath)
261
+ || !sameRecoveryPath(record.storePath, expectedTargets.storePath)
262
+ ) {
263
+ return {
264
+ ok: false,
265
+ error: "recovery_required",
266
+ detail: `journal at ${journalPath} does not match the active prompt-layer paths; nothing was read from its targets`,
267
+ };
268
+ }
269
+
270
+ const config = classify(readOrNull(expectedTargets.configPath), record.preConfig, record.postConfig);
271
+ const store = classify(readOrNull(expectedTargets.storePath), record.preStore, record.postStore);
235
272
 
236
273
  if (config === "unknown" || store === "unknown") {
237
- const which = config === "unknown" ? record.configPath : record.storePath;
274
+ const which = config === "unknown" ? expectedTargets.configPath : expectedTargets.storePath;
238
275
  return {
239
276
  ok: false,
240
277
  error: "recovery_required",
@@ -258,38 +295,38 @@ export function recoverIfNeeded(journalPath: string): RecoveryOutcome {
258
295
  // Revalidate each target immediately before its own restore: a target that
259
296
  // changed since the initial classification must not be overwritten.
260
297
  if (config === "post") {
261
- if (classify(readOrNull(record.configPath), record.preConfig, record.postConfig) !== "post") {
298
+ if (classify(readOrNull(expectedTargets.configPath), record.preConfig, record.postConfig) !== "post") {
262
299
  return {
263
300
  ok: false,
264
301
  error: "recovery_required",
265
- detail: `${record.configPath} changed after its first classification; refusing to overwrite it`,
302
+ detail: `${expectedTargets.configPath} changed after its first classification; refusing to overwrite it`,
266
303
  };
267
304
  }
268
305
  try {
269
- restore(record.configPath, record.preConfigBytes);
306
+ restore(expectedTargets.configPath, record.preConfigBytes);
270
307
  } catch (error) {
271
308
  return {
272
309
  ok: false,
273
310
  error: "recovery_required",
274
- detail: `rollback of ${record.configPath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
311
+ detail: `rollback of ${expectedTargets.configPath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
275
312
  };
276
313
  }
277
314
  }
278
315
  if (store === "post") {
279
- if (classify(readOrNull(record.storePath), record.preStore, record.postStore) !== "post") {
316
+ if (classify(readOrNull(expectedTargets.storePath), record.preStore, record.postStore) !== "post") {
280
317
  return {
281
318
  ok: false,
282
319
  error: "recovery_required",
283
- detail: `${record.storePath} changed after its first classification; refusing to overwrite it`,
320
+ detail: `${expectedTargets.storePath} changed after its first classification; refusing to overwrite it`,
284
321
  };
285
322
  }
286
323
  try {
287
- restore(record.storePath, record.preStoreBytes);
324
+ restore(expectedTargets.storePath, record.preStoreBytes);
288
325
  } catch (error) {
289
326
  return {
290
327
  ok: false,
291
328
  error: "recovery_required",
292
- detail: `rollback of ${record.storePath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
329
+ detail: `rollback of ${expectedTargets.storePath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
293
330
  };
294
331
  }
295
332
  }