sneakoscope 6.7.0 → 7.0.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 (34) hide show
  1. package/README.md +3 -3
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/cli/install-helpers-codex-lb-config.js +71 -0
  5. package/dist/cli/install-helpers-codex-lb-selftest.js +11 -14
  6. package/dist/cli/install-helpers.js +305 -53
  7. package/dist/config/skills-manifest.json +52 -52
  8. package/dist/core/agents/agent-orchestrator.js +56 -5
  9. package/dist/core/agents/agent-roster.js +4 -2
  10. package/dist/core/codex-app/codex-app-fast-ui-repair.js +18 -1
  11. package/dist/core/codex-app/codex-app-ui-state-snapshot.js +16 -0
  12. package/dist/core/codex-app.js +52 -6
  13. package/dist/core/hooks-runtime.js +77 -3
  14. package/dist/core/managed-assets/managed-assets-manifest.js +1 -1
  15. package/dist/core/release/package-size-budget.js +1 -1
  16. package/dist/core/subagents/official-subagent-config.js +8 -5
  17. package/dist/core/subagents/official-subagent-preparation.js +24 -1
  18. package/dist/core/subagents/official-subagent-prompt.js +3 -2
  19. package/dist/core/subagents/wave-lifecycle.js +20 -0
  20. package/dist/core/subagents/wave-parent-guidance.js +42 -0
  21. package/dist/core/version.js +1 -1
  22. package/dist/native/sks-menubar/Sources/AppIdentity.swift +3 -1
  23. package/dist/native/sks-menubar/Sources/ControlCenterWindowController.swift +14 -8
  24. package/dist/native/sks-menubar/Sources/DiagnosticsViewController.swift +71 -7
  25. package/dist/native/sks-menubar/Sources/MCPServersViewController.swift +50 -9
  26. package/dist/native/sks-menubar/Sources/OverviewViewController.swift +61 -8
  27. package/dist/native/sks-menubar/Sources/ProvidersViewController.swift +120 -29
  28. package/dist/native/sks-menubar/Sources/RemoteTelegramViewController.swift +58 -23
  29. package/dist/native/sks-menubar/Sources/SettingsViewController.swift +41 -7
  30. package/dist/native/sks-menubar/Sources/StatusItemController.swift +34 -3
  31. package/dist/native/sks-menubar/Sources/UpdatesViewController.swift +38 -8
  32. package/dist/scripts/docs-truthfulness-check.js +2 -2
  33. package/dist/scripts/doctor-fixes-codex-app-fast-ui-check.js +7 -3
  34. package/package.json +1 -1
@@ -28,7 +28,7 @@ import { repairCodexImagegen } from '../core/doctor/imagegen-repair.js';
28
28
  import { canAskYesNo, compareVersions, hasCodexUnstableFeatureWarningSuppression, hasDeprecatedCodexHooksFeatureFlag, hasTopLevelCodexModeLock, isProjectSetupCandidate } from './install-tool-helpers.js';
29
29
  import { checkCodexLbResponseChain } from './install-helpers-codex-lb-chain.js';
30
30
  import { CODEX_LB_CANONICAL_FAST_SERVICE_TIER, CODEX_LB_PROVIDER_ENV_KEY, CODEX_LB_PROVIDER_NAME, askPostinstallQuestion, codexAuthChatgptBackupPath, codexAuthPath, codexLbConfigPath, codexLbEnvPath, hasTopLevelCodexLbSelected, normalizeCodexLbBaseUrl, parseCodexLbEnvKey, redactSecretText } from './install-helpers-codex-lb-shared.js';
31
- import { appliedCodexLbPersistenceModes, captureCodexLbSetupWriteState, detectCodexLbSetupDrift, ensureGlobalCodexAppGlmProfile, parseCodexLbEnvBaseUrl, parseCodexSharedLoginApiKey, sha256Text, shellSingleQuote, upsertCodexAppGlmConfig, upsertCodexLbConfig } from './install-helpers-codex-lb-config.js';
31
+ import { appliedCodexLbPersistenceModes, captureCodexLbSetupWriteState, codexLbSharedOpenAiRoutingState, detectCodexLbSetupDrift, ensureGlobalCodexAppGlmProfile, parseCodexLbEnvBaseUrl, parseCodexSharedLoginApiKey, removeCodexLbSharedOpenAiRouting, sha256Text, shellSingleQuote, upsertCodexAppGlmConfig, upsertCodexLbConfig, upsertCodexLbSharedOpenAiRouting } from './install-helpers-codex-lb-config.js';
32
32
  import { ensureCodexImagegenDuringInstall, ensureGlobalCodexSkillsDuringInstall, ensureGlobalContext7DuringInstall, ensureGlobalGetdesignSkillDuringInstall, ensureSksCommandDuringInstall } from './install-helpers-install-support.js';
33
33
  function packagedSksEntrypoint() {
34
34
  return path.join(packageRoot(), 'dist', 'bin', 'sks.js');
@@ -383,8 +383,11 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
383
383
  const catalogPath = codexLbToolCatalogPath(codexHome);
384
384
  const current = await readText(input.configPath, '');
385
385
  const selected = hasTopLevelCodexLbSelected(current);
386
+ // Setup writes the catalog pointer before selecting the provider so Desktop can
387
+ // load GPT-5.6/tool metadata the moment model_provider becomes codex-lb.
388
+ const canBindCatalog = selected || opts.allowUnselectedCatalogWrite === true;
386
389
  const existingCatalogPath = topLevelTomlString(current, 'model_catalog_json');
387
- if (selected && existingCatalogPath && path.resolve(existingCatalogPath) !== path.resolve(catalogPath)) {
390
+ if (canBindCatalog && existingCatalogPath && path.resolve(existingCatalogPath) !== path.resolve(catalogPath)) {
388
391
  return {
389
392
  schema: 'sks.codex-lb-tool-catalog-selection.v1',
390
393
  ok: false,
@@ -393,6 +396,7 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
393
396
  path: catalogPath,
394
397
  configured_path: existingCatalogPath,
395
398
  config_changed: false,
399
+ selected,
396
400
  blockers: ['codex_lb_user_model_catalog_conflict']
397
401
  };
398
402
  }
@@ -410,6 +414,7 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
410
414
  status: 'skipped_reserved_host',
411
415
  path: catalogPath,
412
416
  config_changed: false,
417
+ selected,
413
418
  blockers: []
414
419
  };
415
420
  }
@@ -421,7 +426,7 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
421
426
  timeoutMs: Number(opts.toolCatalogTimeoutMs || 5000),
422
427
  force: opts.forceToolCatalog === true
423
428
  });
424
- if (!catalog.ok || !selected) {
429
+ if (!catalog.ok || !canBindCatalog) {
425
430
  return {
426
431
  ...catalog,
427
432
  schema: 'sks.codex-lb-tool-catalog-selection.v1',
@@ -431,7 +436,7 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
431
436
  }
432
437
  const next = ensureTrailingNewline(upsertTopLevelTomlString(current, 'model_catalog_json', catalog.path));
433
438
  if (next === ensureTrailingNewline(current)) {
434
- return { ...catalog, schema: 'sks.codex-lb-tool-catalog-selection.v1', config_changed: false, selected: true };
439
+ return { ...catalog, schema: 'sks.codex-lb-tool-catalog-selection.v1', config_changed: false, selected };
435
440
  }
436
441
  const safeWrite = await safeWriteCodexConfigToml(input.configPath, current, next, 'codex-lb-tool-catalog');
437
442
  return {
@@ -441,7 +446,7 @@ async function ensureCodexLbToolCatalogSelection(input, opts = {}) {
441
446
  status: safeWrite.ok ? catalog.status : safeWrite.status,
442
447
  config_changed: safeWrite.ok && safeWrite.changed === true,
443
448
  backup_path: safeWrite.backup_path,
444
- selected: true,
449
+ selected,
445
450
  blockers: safeWrite.ok ? catalog.blockers : [...new Set([...(catalog.blockers || []), 'codex_lb_tool_catalog_config_write_failed'])]
446
451
  };
447
452
  }
@@ -509,16 +514,17 @@ export async function configureCodexLb(opts = {}) {
509
514
  const beforeState = await captureCodexLbSetupWriteState({ home, configPath, envPath, shellProfile });
510
515
  const appliedActions = [];
511
516
  await ensureDir(path.dirname(configPath));
517
+ // Write the provider table first without selecting it. Desktop model/tool UI
518
+ // exposure depends on a validated catalog; selecting before that leaves the App
519
+ // on codex-lb with no GPT-5.6 picker surface.
512
520
  const current = await readText(configPath, '');
513
- const next = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, baseUrl, useDefaultProvider), {
521
+ const providerOnly = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, baseUrl, false), {
514
522
  forceFastMode: opts.forceFastMode !== false
515
523
  });
516
- const safeWrite = await safeWriteCodexConfigToml(configPath, current, next, 'codex-lb');
524
+ const safeWrite = await safeWriteCodexConfigToml(configPath, current, providerOnly, 'codex-lb');
517
525
  if (!safeWrite.ok)
518
526
  return { ok: false, status: safeWrite.status, config_path: configPath, env_path: envPath, backup_path: safeWrite.backup_path };
519
527
  appliedActions.push({ type: 'write_config_provider', target: configPath, ok: true, backup_path: safeWrite.backup_path });
520
- if (useDefaultProvider)
521
- appliedActions.push({ type: 'select_default_provider', target: configPath, ok: true });
522
528
  if (writeEnvFile) {
523
529
  await writeTextAtomic(envPath, `export CODEX_LB_BASE_URL=${shellSingleQuote(baseUrl)}\nexport CODEX_LB_API_KEY=${shellSingleQuote(apiKey)}\n`, { mode: 0o600 });
524
530
  await fsp.chmod(envPath, 0o600).catch(() => { });
@@ -526,10 +532,48 @@ export async function configureCodexLb(opts = {}) {
526
532
  }
527
533
  process.env.CODEX_LB_BASE_URL = baseUrl;
528
534
  process.env.CODEX_LB_API_KEY = apiKey;
529
- const toolCatalog = await ensureCodexLbToolCatalogSelection({ home, configPath, baseUrl, apiKey }, opts);
535
+ let toolCatalog = await ensureCodexLbToolCatalogSelection({ home, configPath, baseUrl, apiKey }, { ...opts, allowUnselectedCatalogWrite: true });
530
536
  if (toolCatalog.config_changed || toolCatalog.status === 'repaired' || toolCatalog.status === 'cached_compatible') {
531
537
  appliedActions.push({ type: 'write_model_tool_catalog', target: toolCatalog.path, ok: toolCatalog.ok === true, status: toolCatalog.status });
532
538
  }
539
+ const catalogReadyForSelection = toolCatalog.ok === true
540
+ && (toolCatalog.required === false || ['repaired', 'cached_compatible', 'cached_compatible_after_refresh_failed', 'cached_compatible_after_refresh_rejected', 'skipped_reserved_host', 'present'].includes(String(toolCatalog.status || '')));
541
+ let providerSelected = false;
542
+ if (useDefaultProvider && catalogReadyForSelection) {
543
+ const beforeSelect = await readText(configPath, '');
544
+ const selectedConfig = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(beforeSelect, baseUrl, true), {
545
+ forceFastMode: opts.forceFastMode !== false
546
+ });
547
+ const selectWrite = await safeWriteCodexConfigToml(configPath, beforeSelect, selectedConfig, 'codex-lb-select');
548
+ if (!selectWrite.ok) {
549
+ return {
550
+ ok: false,
551
+ status: selectWrite.status || 'provider_selection_failed',
552
+ config_path: configPath,
553
+ env_path: envPath,
554
+ backup_path: selectWrite.backup_path,
555
+ tool_catalog: toolCatalog,
556
+ tool_output_recovery: toolOutputRecovery,
557
+ error: 'codex_lb_provider_selection_write_failed'
558
+ };
559
+ }
560
+ providerSelected = true;
561
+ appliedActions.push({ type: 'select_default_provider', target: configPath, ok: true, backup_path: selectWrite.backup_path });
562
+ // Re-bind after selection so model_catalog_json stays attached to the active provider.
563
+ toolCatalog = await ensureCodexLbToolCatalogSelection({ home, configPath, baseUrl, apiKey }, opts);
564
+ if (toolCatalog.config_changed) {
565
+ appliedActions.push({ type: 'write_model_tool_catalog', target: toolCatalog.path, ok: toolCatalog.ok === true, status: toolCatalog.status });
566
+ }
567
+ }
568
+ else if (useDefaultProvider) {
569
+ appliedActions.push({
570
+ type: 'select_default_provider',
571
+ target: configPath,
572
+ ok: false,
573
+ status: 'deferred_until_tool_catalog_ready',
574
+ blockers: toolCatalog.blockers || ['codex_lb_gpt56_tool_catalog_not_ready']
575
+ });
576
+ }
533
577
  const keyFingerprint = await sha256Text(apiKey);
534
578
  const metadataPath = opts.metadataPath || codexLbMetadataPath(home);
535
579
  await writeTextAtomic(metadataPath, `${JSON.stringify({
@@ -551,11 +595,18 @@ export async function configureCodexLb(opts = {}) {
551
595
  if (shellProfile !== 'skip')
552
596
  appliedActions.push({ type: 'install_shell_profile_snippet', target: shellProfileResult.files?.join(', ') || shellProfile, ok: shellProfileResult.ok === true, status: shellProfileResult.status });
553
597
  const codexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
554
- const forceCodexLbApiKeyAuth = opts.forceCodexLbApiKeyAuth === true || opts.authMode === 'codex-lb';
555
- const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, home, status: codexLb, forceCodexLbApiKeyAuth }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
556
- const codexLogin = forceCodexLbApiKeyAuth
557
- ? { ok: ['apikey_forced', 'apikey_auth_active'].includes(authReconcile.status), status: authReconcile.status, ...(authReconcile.reason ? { reason: authReconcile.reason } : {}), error: authReconcile.error || null }
558
- : await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home, force: true });
598
+ // Never activate shared sk-clb auth while the Desktop picker is still missing a
599
+ // ready catalog/selection that leaves App threads calling api.openai.com.
600
+ const wantForceCodexLbApiKeyAuth = opts.forceCodexLbApiKeyAuth === true || opts.authMode === 'codex-lb';
601
+ const forceCodexLbApiKeyAuth = wantForceCodexLbApiKeyAuth && providerSelected;
602
+ const authReconcile = providerSelected
603
+ ? await reconcileCodexLbAuthConflict({ ...opts, home, status: codexLb, forceCodexLbApiKeyAuth }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }))
604
+ : { status: 'deferred_until_provider_selected', reason: 'tool_catalog_or_selection_incomplete' };
605
+ const codexLogin = !providerSelected
606
+ ? { ok: true, status: 'deferred_until_provider_selected', reason: 'tool_catalog_or_selection_incomplete', error: null }
607
+ : forceCodexLbApiKeyAuth
608
+ ? { ok: ['apikey_forced', 'apikey_auth_active'].includes(authReconcile.status), status: authReconcile.status, ...(authReconcile.reason ? { reason: authReconcile.reason } : {}), error: authReconcile.error || null }
609
+ : await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home, force: true });
559
610
  const finalCodexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
560
611
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
561
612
  const afterState = await captureCodexLbSetupWriteState({ home, configPath, envPath, shellProfile });
@@ -636,6 +687,7 @@ export async function codexLbStatus(opts = {}) {
636
687
  const authPath = opts.authPath || codexAuthPath(home);
637
688
  const authText = await readText(authPath, '');
638
689
  const authMode = codexAuthModeSummary(authText);
690
+ const authApiKey = parseCodexAuthApiKey(authText);
639
691
  const envKeyConfigured = Boolean(envLoad.api_key.present);
640
692
  const providerConfigured = /\[model_providers\.codex-lb\]/.test(config);
641
693
  const selected = hasTopLevelCodexLbSelected(config);
@@ -646,6 +698,10 @@ export async function codexLbStatus(opts = {}) {
646
698
  const providerRequiresOpenAiAuth = codexLbProviderRequiresOpenAiAuth(config);
647
699
  const providerOpenAiAuthDisabled = codexLbProviderOpenAiAuthDisabled(config);
648
700
  const providerEnvKey = codexLbProviderEnvKey(config);
701
+ const sharedOpenAiRouting = codexLbSharedOpenAiRoutingState(config, baseUrl);
702
+ const codexLbKeyInSharedAuth = Boolean(authApiKey && envLoad.secret_api_key && authApiKey === envLoad.secret_api_key);
703
+ const sharedOpenAiRoutingSafe = !codexLbKeyInSharedAuth || sharedOpenAiRouting.status === 'matched';
704
+ const authRoutingCoherent = selected === codexLbKeyInSharedAuth && sharedOpenAiRoutingSafe;
649
705
  const providerContractOk = providerConfigured
650
706
  && providerName === CODEX_LB_PROVIDER_NAME
651
707
  && providerWireApi === 'responses'
@@ -661,7 +717,7 @@ export async function codexLbStatus(opts = {}) {
661
717
  status: 'inspect_failed',
662
718
  error: err.message
663
719
  }));
664
- const providerReady = providerContractOk && envKeyConfigured && Boolean(baseUrl) && authMode.codex_app_usable;
720
+ const providerReady = providerContractOk && envKeyConfigured && Boolean(baseUrl) && authMode.codex_app_usable && authRoutingCoherent;
665
721
  const probeToolOutputRecovery = opts.probeToolOutputRecovery === true;
666
722
  const toolOutputRecovery = !selected
667
723
  ? codexLbToolOutputRecoveryNotSelected()
@@ -704,8 +760,20 @@ export async function codexLbStatus(opts = {}) {
704
760
  base_url: baseUrl,
705
761
  auth_path: authPath,
706
762
  auth_mode: authMode.mode,
707
- auth_usable_for_codex_app: authMode.codex_app_usable || codexAppUsableWithCodexLb,
708
- auth_summary: codexAppUsableWithCodexLb ? `codex-lb provider uses ${authMode.mode} OpenAI-style auth through Codex App` : authMode.summary,
763
+ codex_lb_key_in_shared_auth: codexLbKeyInSharedAuth,
764
+ auth_routing_coherent: authRoutingCoherent,
765
+ shared_openai_routing: {
766
+ status: sharedOpenAiRouting.status,
767
+ safe: sharedOpenAiRoutingSafe,
768
+ managed: sharedOpenAiRouting.managed,
769
+ configured_base_url: sharedOpenAiRouting.configured_base_url
770
+ },
771
+ auth_usable_for_codex_app: authMode.codex_app_usable && authRoutingCoherent,
772
+ auth_summary: !sharedOpenAiRoutingSafe
773
+ ? 'codex-lb key is active in shared auth without a verified built-in OpenAI routing guard'
774
+ : !authRoutingCoherent
775
+ ? 'Codex provider selection and shared auth mode do not form a coherent codex-lb/OAuth state'
776
+ : codexAppUsableWithCodexLb ? `codex-lb provider uses ${authMode.mode} OpenAI-style auth through Codex App` : authMode.summary,
709
777
  fast_mode: fastMode,
710
778
  launch_environment: launchEnvironment,
711
779
  tool_output_recovery: toolOutputRecovery
@@ -721,7 +789,9 @@ export function formatCodexLbStatusText(status = {}, opts = {}) {
721
789
  `Selected: ${status.selected ? 'yes' : 'no'}`,
722
790
  `Provider: ${status.provider_contract_ok ? 'codex-lb App contract ok' : status.provider_configured ? 'drifted' : 'missing'}`,
723
791
  `Provider OpenAI Auth: ${status.provider_requires_openai_auth ? 'required' : 'not required/drifted'} (${status.provider_name || 'missing'})`,
724
- `Codex App auth: ${status.auth_usable_for_codex_app ? 'ok' : 'needs sign-in/repair'} (${status.auth_mode || 'unknown'})`
792
+ `Codex App auth: ${status.auth_usable_for_codex_app ? 'ok' : 'needs sign-in/repair'} (${status.auth_mode || 'unknown'})`,
793
+ `Shared OpenAI routing: ${status.shared_openai_routing?.safe === false ? 'unsafe' : status.shared_openai_routing?.status || 'unknown'}${status.shared_openai_routing?.managed ? ' (sks-managed)' : ''}`,
794
+ `Auth/routing coherent: ${status.auth_routing_coherent ? 'yes' : 'no'}`
725
795
  ];
726
796
  if (status.tool_output_recovery?.status && status.tool_output_recovery.status !== 'not_selected') {
727
797
  const recovery = status.tool_output_recovery;
@@ -743,7 +813,9 @@ export function formatCodexLbStatusText(status = {}, opts = {}) {
743
813
  if (status.base_url)
744
814
  lines.push(`Base URL: ${status.base_url}`);
745
815
  lines.push(`ChatGPT backup: ${backupPresent ? `yes (${backupPath})` : 'no'}`);
746
- if (status.provider_configured && !status.provider_contract_ok)
816
+ if (status.shared_openai_routing?.safe === false)
817
+ lines.push('', 'Run: sks codex-lb use-codex-lb to pin built-in OpenAI routing to the codex-lb base URL before the shared key can hit api.openai.com.');
818
+ else if (status.provider_configured && !status.provider_contract_ok)
747
819
  lines.push('', 'Run: sks codex-lb repair to rewrite the provider block to the current codex-lb App contract.');
748
820
  else if (status.ok && !status.auth_usable_for_codex_app && backupPresent)
749
821
  lines.push('', 'Run: sks codex-lb use-oauth to restore ChatGPT OAuth, or sks codex-lb use-codex-lb to force codex-lb API-key auth.');
@@ -754,7 +826,7 @@ export function formatCodexLbStatusText(status = {}, opts = {}) {
754
826
  else if (status.ok)
755
827
  lines.push('', 'Status: codex-lb active; no repair needed.');
756
828
  else if (!status.ok && status.base_url && status.env_key_configured)
757
- lines.push('', 'Run: sks codex-lb repair to restore the upstream codex-lb provider block.');
829
+ lines.push('', 'Run: sks codex-lb repair / use-codex-lb to restore a coherent provider + shared-auth routing state.');
758
830
  else if (!status.ok)
759
831
  lines.push('', 'Run: sks codex-lb setup --host <domain> --api-key-stdin');
760
832
  if (backupPresent)
@@ -878,7 +950,10 @@ export async function repairCodexLbAuth(opts = {}) {
878
950
  configRepaired = safeWrite.ok && safeWrite.changed === true;
879
951
  status = await codexLbStatus({ ...opts, probeToolOutputRecovery: true });
880
952
  }
881
- const canRepairAuthMode = opts.forceCodexLbApiKeyAuth === true && status.provider_contract_ok && status.env_key_configured && Boolean(status.base_url);
953
+ const canRepairAuthMode = (opts.forceCodexLbApiKeyAuth === true || status.codex_lb_key_in_shared_auth === true)
954
+ && status.provider_contract_ok
955
+ && status.env_key_configured
956
+ && Boolean(status.base_url);
882
957
  if (!status.ok && !canRepairAuthMode) {
883
958
  return {
884
959
  ok: false,
@@ -1064,11 +1139,11 @@ async function migrateCodexAuthKeyFormat(opts = {}) {
1064
1139
  return { status: 'skipped', reason: 'parse_error' };
1065
1140
  }
1066
1141
  }
1067
- // codex-lb's current Codex App contract uses a custom provider named "openai"
1068
- // with requires_openai_auth=true. CODEX_LB_API_KEY remains SKS's persisted key
1069
- // source, but selecting codex-lb auth must also switch Codex's OpenAI-style
1070
- // auth.json to that API key; otherwise the App can appear configured while still
1071
- // running under a different ChatGPT/OAuth auth path.
1142
+ // Codex App uses the shared OpenAI auth store for providers with
1143
+ // requires_openai_auth=true. Before putting the codex-lb key in that store, SKS
1144
+ // must also guard the built-in `openai` provider with the same LB base URL. This
1145
+ // covers stale App threads and explicit CLI overrides that retain `openai`; the
1146
+ // config guard is written and verified before auth.json changes.
1072
1147
  export async function reconcileCodexLbAuthConflict(opts = {}) {
1073
1148
  const home = opts.home || process.env.HOME || os.homedir();
1074
1149
  const status = opts.status || await codexLbStatus({ ...opts, home });
@@ -1086,19 +1161,111 @@ export async function reconcileCodexLbAuthConflict(opts = {}) {
1086
1161
  return { status: 'skipped', reason: 'missing_env_key', auth_path: authPath };
1087
1162
  }
1088
1163
  const forceCodexLbApiKeyAuth = opts.forceCodexLbApiKeyAuth === true;
1164
+ const currentApiKey = parseCodexAuthApiKey(authText);
1165
+ let routingGuardAdded = false;
1166
+ const ensureSharedOpenAiRouting = async () => {
1167
+ const configPath = opts.configPath || status.config_path || codexLbConfigPath(home);
1168
+ const currentConfig = await readText(configPath, '');
1169
+ const planned = upsertCodexLbSharedOpenAiRouting(currentConfig, status.base_url);
1170
+ if (!planned.ok) {
1171
+ return {
1172
+ ok: false,
1173
+ status: planned.status,
1174
+ config_path: configPath,
1175
+ configured_base_url: planned.configured_base_url
1176
+ };
1177
+ }
1178
+ if (planned.status === 'present') {
1179
+ return { ok: true, status: 'present', changed: false, config_path: configPath, managed: planned.managed };
1180
+ }
1181
+ const written = await safeWriteCodexConfigToml(configPath, currentConfig, planned.text, 'codex-lb-shared-auth-routing');
1182
+ if (!written.ok)
1183
+ return { ok: false, status: written.status, config_path: configPath, error: 'shared_openai_routing_write_failed' };
1184
+ const after = await readText(configPath, '');
1185
+ const verified = codexLbSharedOpenAiRoutingState(after, status.base_url);
1186
+ if (verified.status !== 'matched' || !verified.managed) {
1187
+ return { ok: false, status: 'readback_failed', config_path: configPath, error: 'shared_openai_routing_readback_failed' };
1188
+ }
1189
+ routingGuardAdded = true;
1190
+ return { ok: true, status: 'added', changed: true, config_path: configPath, managed: true };
1191
+ };
1192
+ const rollbackSharedOpenAiRouting = async () => {
1193
+ if (!routingGuardAdded)
1194
+ return { ok: true, status: 'not_needed' };
1195
+ const configPath = opts.configPath || status.config_path || codexLbConfigPath(home);
1196
+ const currentConfig = await readText(configPath, '');
1197
+ const removal = removeCodexLbSharedOpenAiRouting(currentConfig, status.base_url);
1198
+ if (!removal.changed)
1199
+ return { ok: false, status: 'not_owned_or_changed' };
1200
+ const written = await safeWriteCodexConfigToml(configPath, currentConfig, ensureTrailingNewline(removal.text), 'codex-lb-shared-auth-routing-rollback');
1201
+ if (!written.ok)
1202
+ return { ok: false, status: written.status };
1203
+ const after = codexLbSharedOpenAiRoutingState(await readText(configPath, ''), status.base_url);
1204
+ return { ok: after.status === 'missing' && !after.managed, status: after.status === 'missing' && !after.managed ? 'rolled_back' : 'readback_failed' };
1205
+ };
1206
+ const rollbackAuth = async () => {
1207
+ try {
1208
+ if (authExists)
1209
+ await writeTextAtomic(authPath, authText, { mode: 0o600 });
1210
+ else
1211
+ await fsp.rm(authPath, { force: true });
1212
+ return { ok: true, status: 'rolled_back' };
1213
+ }
1214
+ catch (err) {
1215
+ return { ok: false, status: 'rollback_failed', error: err.message };
1216
+ }
1217
+ };
1089
1218
  const writeApiKeyAuth = async (reason, backupPathForResult = null) => {
1219
+ const routingGuard = await ensureSharedOpenAiRouting();
1220
+ if (!routingGuard.ok) {
1221
+ return {
1222
+ status: 'failed',
1223
+ reason: routingGuard.status === 'conflicting_user_openai_base_url' ? 'shared_openai_base_url_conflict' : 'shared_openai_routing_guard_failed',
1224
+ auth_path: authPath,
1225
+ backup_path: backupPathForResult || backupPath,
1226
+ routing_guard: routingGuard,
1227
+ error: routingGuard.error || routingGuard.status
1228
+ };
1229
+ }
1090
1230
  try {
1091
1231
  await writeTextAtomic(authPath, `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2)}\n`, { mode: 0o600 });
1092
1232
  await fsp.chmod(authPath, 0o600).catch(() => { });
1233
+ const finalConfig = await readText(opts.configPath || status.config_path || codexLbConfigPath(home), '');
1234
+ const finalAuth = await readText(authPath, '');
1235
+ const finalRouting = codexLbSharedOpenAiRoutingState(finalConfig, status.base_url);
1236
+ if (finalRouting.status !== 'matched' || parseCodexAuthApiKey(finalAuth) !== apiKey) {
1237
+ const authRollback = await rollbackAuth();
1238
+ const routingRollback = await rollbackSharedOpenAiRouting();
1239
+ return {
1240
+ status: 'failed',
1241
+ reason: 'activation_readback_failed',
1242
+ auth_path: authPath,
1243
+ backup_path: backupPathForResult || backupPath,
1244
+ routing_guard: routingGuard,
1245
+ routing_rollback: routingRollback,
1246
+ error: authRollback.ok && routingRollback.ok ? 'activation_readback_failed_rolled_back' : 'activation_readback_failed_rollback_incomplete'
1247
+ };
1248
+ }
1093
1249
  return {
1094
1250
  status: 'apikey_forced',
1095
1251
  reason,
1096
1252
  auth_path: authPath,
1097
- backup_path: backupPathForResult || backupPath
1253
+ backup_path: backupPathForResult || backupPath,
1254
+ routing_guard: routingGuard
1098
1255
  };
1099
1256
  }
1100
1257
  catch (err) {
1101
- return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPathForResult || backupPath, error: err.message };
1258
+ const authRollback = await rollbackAuth();
1259
+ const routingRollback = await rollbackSharedOpenAiRouting();
1260
+ return {
1261
+ status: 'failed',
1262
+ reason: 'write_failed',
1263
+ auth_path: authPath,
1264
+ backup_path: backupPathForResult || backupPath,
1265
+ routing_guard: routingGuard,
1266
+ routing_rollback: routingRollback,
1267
+ error: authRollback.ok && routingRollback.ok ? err.message : `${err.message}; activation rollback incomplete`
1268
+ };
1102
1269
  }
1103
1270
  };
1104
1271
  if (!authExists) {
@@ -1138,45 +1305,73 @@ export async function reconcileCodexLbAuthConflict(opts = {}) {
1138
1305
  backup_path: backupPath
1139
1306
  };
1140
1307
  }
1141
- try {
1142
- const replacement = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2)}\n`;
1143
- await writeTextAtomic(authPath, replacement, { mode: 0o600 });
1144
- await fsp.chmod(authPath, 0o600).catch(() => { });
1145
- }
1146
- catch (err) {
1147
- return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
1148
- }
1149
- return {
1150
- status: 'apikey_forced',
1151
- reason: 'SKS_CODEX_LB_FORCE_APIKEY_AUTH=1',
1152
- auth_path: authPath,
1153
- backup_path: backupPath
1154
- };
1308
+ return writeApiKeyAuth('SKS_CODEX_LB_FORCE_APIKEY_AUTH=1', backupPath);
1155
1309
  }
1156
- const currentApiKey = parseCodexAuthApiKey(authText);
1157
1310
  if (forceCodexLbApiKeyAuth) {
1158
1311
  if (currentApiKey && currentApiKey === apiKey) {
1312
+ const routingGuard = await ensureSharedOpenAiRouting();
1313
+ if (!routingGuard.ok) {
1314
+ return {
1315
+ status: 'failed',
1316
+ reason: routingGuard.status === 'conflicting_user_openai_base_url' ? 'shared_openai_base_url_conflict' : 'shared_openai_routing_guard_failed',
1317
+ auth_path: authPath,
1318
+ backup_path: backupPath,
1319
+ routing_guard: routingGuard,
1320
+ error: routingGuard.error || routingGuard.status
1321
+ };
1322
+ }
1159
1323
  return {
1160
1324
  status: 'apikey_auth_active',
1161
1325
  reason: 'codex_lb_auth_selected',
1162
1326
  auth_path: authPath,
1163
- backup_path: backupPath
1327
+ backup_path: backupPath,
1328
+ routing_guard: routingGuard
1164
1329
  };
1165
1330
  }
1166
1331
  return writeApiKeyAuth('codex_lb_auth_selected_replace_existing');
1167
1332
  }
1168
1333
  if (currentApiKey && currentApiKey === apiKey) {
1334
+ const routingGuard = await ensureSharedOpenAiRouting();
1335
+ if (!routingGuard.ok) {
1336
+ return {
1337
+ status: 'failed',
1338
+ reason: routingGuard.status === 'conflicting_user_openai_base_url' ? 'shared_openai_base_url_conflict' : 'shared_openai_routing_guard_failed',
1339
+ auth_path: authPath,
1340
+ backup_path: backupPath,
1341
+ routing_guard: routingGuard,
1342
+ error: routingGuard.error || routingGuard.status
1343
+ };
1344
+ }
1169
1345
  const backupText = await readText(backupPath, '');
1170
1346
  if (hasChatgptOAuthTokens(backupText) && process.env.SKS_CODEX_LB_KEEP_APIKEY_AUTH !== '1') {
1171
1347
  try {
1172
1348
  const restored = backupText.endsWith('\n') ? backupText : `${backupText}\n`;
1173
1349
  await writeTextAtomic(authPath, restored, { mode: 0o600 });
1174
1350
  await fsp.chmod(authPath, 0o600).catch(() => { });
1351
+ const unselected = await unselectCodexLbProvider({
1352
+ ...opts,
1353
+ home,
1354
+ authPath,
1355
+ allowActiveSharedAuthTransition: true
1356
+ });
1357
+ if (!unselected.ok) {
1358
+ await writeTextAtomic(authPath, authText, { mode: 0o600 });
1359
+ await fsp.chmod(authPath, 0o600).catch(() => { });
1360
+ return {
1361
+ status: 'failed',
1362
+ reason: 'oauth_restore_provider_unselect_failed',
1363
+ auth_path: authPath,
1364
+ backup_path: backupPath,
1365
+ routing_guard: routingGuard,
1366
+ error: unselected.provider_error || unselected.reason || unselected.status
1367
+ };
1368
+ }
1175
1369
  return {
1176
1370
  status: 'oauth_restored',
1177
1371
  reason: 'restored_chatgpt_oauth_for_codex_app',
1178
1372
  auth_path: authPath,
1179
- backup_path: backupPath
1373
+ backup_path: backupPath,
1374
+ routing_guard: routingGuard
1180
1375
  };
1181
1376
  }
1182
1377
  catch (err) {
@@ -1187,7 +1382,8 @@ export async function reconcileCodexLbAuthConflict(opts = {}) {
1187
1382
  status: 'apikey_auth_active',
1188
1383
  reason: hasChatgptOAuthTokens(backupText) ? 'SKS_CODEX_LB_KEEP_APIKEY_AUTH=1' : 'chatgpt_oauth_backup_missing',
1189
1384
  auth_path: authPath,
1190
- backup_path: backupPath
1385
+ backup_path: backupPath,
1386
+ routing_guard: routingGuard
1191
1387
  };
1192
1388
  }
1193
1389
  return { status: 'no_oauth_conflict', auth_path: authPath };
@@ -1224,20 +1420,41 @@ export async function unselectCodexLbProvider(opts = {}) {
1224
1420
  const current = await readText(configPath, '');
1225
1421
  if (!current.trim())
1226
1422
  return { ok: true, status: 'not_selected', reason: 'no_config', config_path: configPath };
1423
+ const envPath = opts.envPath || codexLbEnvPath(home);
1424
+ const envLoad = await loadCodexLbEnv({ ...opts, home, envPath });
1425
+ const authPath = opts.authPath || codexAuthPath(home);
1426
+ const authApiKey = parseCodexAuthApiKey(await readText(authPath, ''));
1427
+ const sharedCodexLbAuthActive = Boolean(authApiKey && envLoad.secret_api_key && authApiKey === envLoad.secret_api_key);
1428
+ if (sharedCodexLbAuthActive && opts.allowActiveSharedAuthTransition !== true) {
1429
+ return {
1430
+ ok: false,
1431
+ status: 'failed',
1432
+ reason: 'shared_codex_lb_auth_active',
1433
+ provider_error: 'refusing to unselect codex-lb while its key is active in shared OpenAI auth',
1434
+ config_path: configPath
1435
+ };
1436
+ }
1227
1437
  const managedCatalogPath = codexLbToolCatalogPath(opts.codexHome || path.join(home, '.codex'));
1228
1438
  const managedCatalogSelected = topLevelTomlString(current, 'model_catalog_json') === managedCatalogPath;
1229
- if (!hasTopLevelCodexLbSelected(current) && !managedCatalogSelected)
1439
+ const providerBaseUrl = codexLbProviderBaseUrl(current) || envLoad.base_url || '';
1440
+ const sharedOpenAiRouting = codexLbSharedOpenAiRoutingState(current, providerBaseUrl);
1441
+ const managedSharedOpenAiRouting = sharedOpenAiRouting.status === 'matched' && sharedOpenAiRouting.managed;
1442
+ if (!hasTopLevelCodexLbSelected(current) && !managedCatalogSelected && !managedSharedOpenAiRouting)
1230
1443
  return { ok: true, status: 'not_selected', config_path: configPath };
1231
1444
  try {
1232
1445
  let next = removeTopLevelTomlString(current, 'model_provider');
1233
1446
  next = removeTopLevelTomlKeyIfValue(next, 'model_catalog_json', managedCatalogPath);
1447
+ const routingRemoval = removeCodexLbSharedOpenAiRouting(next, providerBaseUrl);
1448
+ next = routingRemoval.text;
1234
1449
  next = ensureTrailingNewline(next);
1235
1450
  const safeWrite = await safeWriteCodexConfigToml(configPath, current, next, 'codex-lb-unselect');
1236
1451
  const after = safeWrite.ok ? await readText(configPath, '') : current;
1452
+ const afterRouting = codexLbSharedOpenAiRoutingState(after, providerBaseUrl);
1237
1453
  const selectionRemoved = !hasTopLevelCodexLbSelected(after)
1238
- && topLevelTomlString(after, 'model_catalog_json') !== managedCatalogPath;
1454
+ && topLevelTomlString(after, 'model_catalog_json') !== managedCatalogPath
1455
+ && (!managedSharedOpenAiRouting || (afterRouting.status === 'missing' && !afterRouting.managed));
1239
1456
  if (safeWrite.ok && selectionRemoved)
1240
- return { ok: true, status: 'unselected', config_path: configPath, backup_path: safeWrite.backup_path };
1457
+ return { ok: true, status: 'unselected', config_path: configPath, backup_path: safeWrite.backup_path, routing_guard_removed: routingRemoval.changed };
1241
1458
  const providerError = safeWrite.ok ? 'provider_selection_remains_after_write' : safeWrite.status || 'provider_config_write_blocked';
1242
1459
  return {
1243
1460
  ok: false,
@@ -1282,6 +1499,21 @@ export async function releaseCodexLbAuthHold(opts = {}) {
1282
1499
  const authExisted = await exists(authPath);
1283
1500
  const currentAuthText = await readText(authPath, '');
1284
1501
  const trimmedCurrent = currentAuthText.trim();
1502
+ if (opts.keepProvider) {
1503
+ const currentConfig = await readText(configPath, '');
1504
+ const providerBaseUrl = codexLbProviderBaseUrl(currentConfig);
1505
+ const routing = codexLbSharedOpenAiRoutingState(currentConfig, providerBaseUrl);
1506
+ if (hasTopLevelCodexLbSelected(currentConfig) || (routing.status === 'matched' && routing.managed)) {
1507
+ return {
1508
+ ok: false,
1509
+ status: 'failed',
1510
+ reason: 'keep_provider_unsafe_with_shared_auth',
1511
+ auth_path: authPath,
1512
+ backup_path: backupPath,
1513
+ provider_unselected: false
1514
+ };
1515
+ }
1516
+ }
1285
1517
  // Repeated "Use ChatGPT OAuth" is idempotent. If OAuth/browser auth is
1286
1518
  // already active, a historical backup is unnecessary; only ensure that the
1287
1519
  // codex-lb provider is no longer selected.
@@ -1289,7 +1521,13 @@ export async function releaseCodexLbAuthHold(opts = {}) {
1289
1521
  if (!opts.force && (currentAuthMode.mode === 'chatgpt_oauth' || currentAuthMode.mode === 'browser_marker')) {
1290
1522
  let provider = { ok: true, provider_unselected: false, provider_status: 'kept', provider_error: null };
1291
1523
  if (!opts.keepProvider) {
1292
- const unselected = await unselectCodexLbProvider({ ...opts, home, configPath });
1524
+ const unselected = await unselectCodexLbProvider({
1525
+ ...opts,
1526
+ home,
1527
+ configPath,
1528
+ authPath,
1529
+ allowActiveSharedAuthTransition: true
1530
+ });
1293
1531
  provider = providerDeselectionOutcome(unselected);
1294
1532
  }
1295
1533
  return {
@@ -1327,7 +1565,13 @@ export async function releaseCodexLbAuthHold(opts = {}) {
1327
1565
  if (trimmedCurrent && hasChatgptOAuthTokens(currentAuthText) && !opts.force) {
1328
1566
  let provider = { ok: true, provider_unselected: false, provider_status: 'kept', provider_error: null };
1329
1567
  if (!opts.keepProvider) {
1330
- const unselected = await unselectCodexLbProvider({ ...opts, home, configPath });
1568
+ const unselected = await unselectCodexLbProvider({
1569
+ ...opts,
1570
+ home,
1571
+ configPath,
1572
+ authPath,
1573
+ allowActiveSharedAuthTransition: true
1574
+ });
1331
1575
  provider = providerDeselectionOutcome(unselected);
1332
1576
  }
1333
1577
  return {
@@ -1374,7 +1618,15 @@ export async function releaseCodexLbAuthHold(opts = {}) {
1374
1618
  }
1375
1619
  let provider = { ok: true, provider_unselected: false, provider_status: 'kept', provider_error: null };
1376
1620
  if (!opts.keepProvider) {
1377
- const unselected = await unselectCodexLbProvider({ ...opts, home, configPath });
1621
+ // Auth was just restored to ChatGPT OAuth. Allow deselection even if a stale
1622
+ // shared-key read would otherwise refuse, so release cannot leave OAuth + LB pin.
1623
+ const unselected = await unselectCodexLbProvider({
1624
+ ...opts,
1625
+ home,
1626
+ configPath,
1627
+ authPath,
1628
+ allowActiveSharedAuthTransition: true
1629
+ });
1378
1630
  provider = providerDeselectionOutcome(unselected);
1379
1631
  }
1380
1632
  if (!provider.ok) {