sneakoscope 0.9.2 → 0.9.6

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.
package/README.md CHANGED
@@ -191,6 +191,33 @@ Bare `sks` can also prompt for codex-lb auth; SKS stores the base URL/key in `~/
191
191
 
192
192
  If codex-lb provider auth drifts after launch/reinstall, run `sks doctor --fix` or `sks codex-lb repair`; to replace it, run `sks codex-lb reconfigure --host <domain> --api-key <key>`.
193
193
 
194
+ ### Switching back to ChatGPT OAuth (releasing codex-lb)
195
+
196
+ If you want to hand control back to your official ChatGPT account login after codex-lb has been reconciled, use `sks codex-lb release`:
197
+
198
+ ```sh
199
+ sks codex-lb release
200
+ ```
201
+
202
+ This restores `~/.codex/auth.chatgpt-backup.json` (written by the 0.9.3 auto-reconcile) to `~/.codex/auth.json` and unsets `model_provider = "codex-lb"` so Codex CLI/App falls back to ChatGPT OAuth. To re-engage codex-lb afterward, run `sks codex-lb repair`.
203
+
204
+ Flags:
205
+
206
+ - `--keep-provider` — restore `auth.json` only; leave `model_provider = "codex-lb"` selected (advanced use).
207
+ - `--delete-backup` — remove `~/.codex/auth.chatgpt-backup.json` after a successful restore. Default is to keep it so a subsequent re-reconcile still has a source backup.
208
+ - `--force` — restore even when the current `auth.json` does not look like the codex-lb apikey shape (e.g. if you hand-edited it after reconcile).
209
+ - `--json` — machine-readable output with `status` ∈ {`released`, `no_backup`, `already_chatgpt`, `auth_in_use`, `failed`} plus `auth_path`, `backup_path`, `provider_unselected`, `backup_removed`.
210
+
211
+ `sks codex-lb status` reports whether a ChatGPT OAuth backup is present and shows the `sks codex-lb release` hint when applicable. `sks doctor` surfaces the same hint.
212
+
213
+ If you only want to stop routing through codex-lb without touching `auth.json`, use the lighter `sks codex-lb unselect` instead:
214
+
215
+ ```sh
216
+ sks codex-lb unselect
217
+ ```
218
+
219
+ This flips `model_provider` away from `codex-lb` in the top-level Codex App config while leaving your `sks-codex-lb.env` and `auth.json` untouched, so you can re-engage codex-lb later with `sks codex-lb repair` without re-running setup.
220
+
194
221
  ### MAD tmux Launch
195
222
 
196
223
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.9.2",
4
+ "version": "0.9.6",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
@@ -87,6 +87,14 @@ async function reportPostinstallCodexLbAuth() {
87
87
  else if (codexLbAuth.status === 'missing_env_key') console.log('codex-lb auth: stored key missing. Run `sks codex-lb setup --host <domain> --api-key <key>` to repair.');
88
88
  else if (codexLbAuth.status === 'missing_base_url') console.log('codex-lb auth: stored key has no recoverable base URL. Run `sks codex-lb reconfigure --host <domain> --api-key <key>` once.');
89
89
  else if (codexLbAuth.status && codexLbAuth.status !== 'not_configured') console.log(`codex-lb auth: repair skipped (${codexLbAuth.status}${codexLbAuth.error ? `: ${codexLbAuth.error}` : ''}).`);
90
+ const reconcile = codexLbAuth.auth_reconcile;
91
+ if (reconcile?.status === 'reconciled') {
92
+ console.log(`codex-lb auth: resolved ChatGPT OAuth conflict by switching auth.json to apikey mode (OAuth backup at ${reconcile.backup_path}). Set SKS_CODEX_LB_NO_AUTH_RECONCILE=1 to opt out.`);
93
+ } else if (reconcile?.status === 'backup_only') {
94
+ console.log(`codex-lb auth: detected ChatGPT OAuth tokens in auth.json. OAuth backup written to ${reconcile.backup_path}; auth.json left untouched because SKS_CODEX_LB_NO_AUTH_RECONCILE=1.`);
95
+ } else if (reconcile?.status === 'failed') {
96
+ console.log(`codex-lb auth: ChatGPT OAuth reconciliation could not complete (${reconcile.reason || 'unknown'}${reconcile.error ? `: ${reconcile.error}` : ''}). Run \`sks codex-lb repair\` to retry.`);
97
+ }
90
98
  return codexLbAuth;
91
99
  }
92
100
 
@@ -160,26 +168,65 @@ function codexAuthPath(home = process.env.HOME || os.homedir()) {
160
168
  return path.join(home, '.codex', 'auth.json');
161
169
  }
162
170
 
171
+ function codexAuthChatgptBackupPath(home = process.env.HOME || os.homedir()) {
172
+ return path.join(home, '.codex', 'auth.chatgpt-backup.json');
173
+ }
174
+
163
175
  async function capturePostinstallCodexLbConfigSnapshot(home = process.env.HOME || os.homedir()) {
164
176
  const configPath = codexLbConfigPath(home);
165
177
  const envPath = codexLbEnvPath(home);
178
+ const authPath = codexAuthPath(home);
166
179
  const config = await readText(configPath, '');
167
180
  const envText = await readText(envPath, '');
168
- if (!parseCodexLbEnvKey(envText)) return null;
181
+ const authExisted = await exists(authPath);
182
+ const authText = authExisted ? await readText(authPath, '') : '';
183
+ const envKey = parseCodexLbEnvKey(envText);
184
+ const providerConfigured = /\[model_providers\.codex-lb\]/.test(config);
169
185
  const baseUrl = codexLbProviderBaseUrl(config) || parseCodexLbEnvBaseUrl(envText);
170
- if (!baseUrl) return null;
171
- return { config_path: configPath, env_path: envPath, base_url: normalizeCodexLbBaseUrl(baseUrl) };
186
+ // Snapshot any codex-lb-related state so the upgrade-time bootstrap can't silently undo it.
187
+ if (!envKey && !providerConfigured && !authExisted) return null;
188
+ return {
189
+ config_path: configPath,
190
+ env_path: envPath,
191
+ auth_path: authPath,
192
+ base_url: baseUrl ? normalizeCodexLbBaseUrl(baseUrl) : null,
193
+ auth_existed: authExisted,
194
+ auth_text: authText
195
+ };
172
196
  }
173
197
 
174
198
  async function restorePostinstallCodexLbConfigSnapshot(snapshot) {
175
- if (!snapshot?.base_url) return { status: 'skipped', reason: 'no_snapshot' };
176
- const current = await readText(snapshot.config_path, '');
177
- const next = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, snapshot.base_url));
178
- if (next === ensureTrailingNewline(current) && codexLbProviderBaseUrl(current)) {
179
- return { status: 'present', config_path: snapshot.config_path };
199
+ if (!snapshot) return { status: 'skipped', reason: 'no_snapshot' };
200
+ let configRestored = false;
201
+ if (snapshot.base_url) {
202
+ const current = await readText(snapshot.config_path, '');
203
+ const next = normalizeCodexFastModeUiConfig(upsertCodexLbConfig(current, snapshot.base_url));
204
+ const alreadyOk = next === ensureTrailingNewline(current) && codexLbProviderBaseUrl(current);
205
+ if (!alreadyOk) {
206
+ await writeTextAtomic(snapshot.config_path, next);
207
+ configRestored = true;
208
+ }
180
209
  }
181
- await writeTextAtomic(snapshot.config_path, next);
182
- return { status: 'restored', config_path: snapshot.config_path };
210
+ // Restore auth.json only if bootstrap accidentally wiped or emptied a pre-existing auth.json.
211
+ // We do NOT clobber a legitimately rewritten auth.json; we just heal the disappearing-auth regression.
212
+ let authRestored = false;
213
+ if (snapshot.auth_existed && snapshot.auth_text && snapshot.auth_text.trim()) {
214
+ const currentAuthExists = await exists(snapshot.auth_path);
215
+ const currentAuthText = currentAuthExists ? await readText(snapshot.auth_path, '') : '';
216
+ if (!currentAuthExists || !currentAuthText.trim()) {
217
+ await ensureDir(path.dirname(snapshot.auth_path));
218
+ await writeTextAtomic(snapshot.auth_path, snapshot.auth_text);
219
+ await fsp.chmod(snapshot.auth_path, 0o600).catch(() => {});
220
+ authRestored = true;
221
+ }
222
+ }
223
+ return {
224
+ status: configRestored || authRestored ? 'restored' : 'present',
225
+ config_path: snapshot.config_path,
226
+ auth_path: snapshot.auth_path,
227
+ config_restored: configRestored,
228
+ auth_restored: authRestored
229
+ };
183
230
  }
184
231
 
185
232
  export function normalizeCodexLbBaseUrl(input = '') {
@@ -413,6 +460,7 @@ export async function repairCodexLbAuth(opts = {}) {
413
460
  const codexEnvironment = await syncCodexLbProviderEnvironment(status, opts);
414
461
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
415
462
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
463
+ const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
416
464
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
417
465
  return {
418
466
  ok,
@@ -423,6 +471,7 @@ export async function repairCodexLbAuth(opts = {}) {
423
471
  config_repaired: configRepaired,
424
472
  legacy_auth_migrated: legacyAuthMigrated,
425
473
  legacy_auth_path: legacyAuthPath,
474
+ auth_reconcile: authReconcile,
426
475
  codex_lb: status,
427
476
  codex_environment: codexEnvironment,
428
477
  codex_login: codexLogin
@@ -441,6 +490,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
441
490
  const codexEnvironment = await syncCodexLbProviderEnvironment(status, opts);
442
491
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
443
492
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
493
+ const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
444
494
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
445
495
  return {
446
496
  ok,
@@ -451,6 +501,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
451
501
  codex_lb: status,
452
502
  codex_environment: codexEnvironment,
453
503
  codex_login: codexLogin,
504
+ auth_reconcile: authReconcile,
454
505
  error: codexEnvironment.error || codexLogin.error || null
455
506
  };
456
507
  }
@@ -470,6 +521,253 @@ async function restoreCodexLbEnvFromSharedLogin(status = {}, opts = {}) {
470
521
  return { ok: true, status: 'migrated_login_cache', auth_path: authPath, env_path: envPath, base_url: normalizeCodexLbBaseUrl(baseUrl) };
471
522
  }
472
523
 
524
+ // Detects a real ChatGPT OAuth token blob in auth.json.
525
+ // A bare {"auth_mode":"browser"} marker is NOT considered an OAuth token blob — we preserve it.
526
+ function hasChatgptOAuthTokens(text = '') {
527
+ try {
528
+ const parsed = JSON.parse(String(text || ''));
529
+ if (!parsed || typeof parsed !== 'object') return false;
530
+ const authMode = String(parsed.auth_mode || parsed.authMode || parsed.mode || '').toLowerCase();
531
+ const tokens = parsed.tokens || parsed.oauth || parsed.oauth_tokens;
532
+ if (tokens && typeof tokens === 'object') {
533
+ if (tokens.id_token || tokens.access_token || tokens.refresh_token) return true;
534
+ }
535
+ if (authMode && /chatgpt|oauth|browser/.test(authMode)) {
536
+ // Only treat as an OAuth blob when real tokens or a refresh metadata trail are also present.
537
+ if (parsed.last_refresh || parsed.expires_at || parsed.refresh_token || parsed.access_token || parsed.id_token) return true;
538
+ }
539
+ return false;
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+
545
+ function parseCodexAuthApiKey(text = '') {
546
+ try {
547
+ const parsed = JSON.parse(String(text || ''));
548
+ const key = parsed?.key || parsed?.api_key || parsed?.apiKey || parsed?.openai_api_key || parsed?.OPENAI_API_KEY;
549
+ return typeof key === 'string' ? key.trim() : '';
550
+ } catch {
551
+ return '';
552
+ }
553
+ }
554
+
555
+ // When codex-lb is selected with env_key auth AND auth.json also carries a real ChatGPT OAuth
556
+ // token blob, Codex CLI/App can pick the OAuth bearer over the env_key bearer and fail against
557
+ // the load balancer. We back the OAuth blob up to ~/.codex/auth.chatgpt-backup.json and replace
558
+ // auth.json with an apikey-mode payload that matches the stored CODEX_LB_API_KEY.
559
+ // Opt out with SKS_CODEX_LB_NO_AUTH_RECONCILE=1 (the backup is still produced so nothing is lost).
560
+ export async function reconcileCodexLbAuthConflict(opts = {}) {
561
+ const home = opts.home || process.env.HOME || os.homedir();
562
+ const status = opts.status || await codexLbStatus({ ...opts, home });
563
+ const authPath = opts.authPath || codexAuthPath(home);
564
+ const backupPath = opts.backupPath || codexAuthChatgptBackupPath(home);
565
+ if (!status.env_key_configured || !status.base_url) {
566
+ return { status: 'skipped', reason: 'codex_lb_not_ready', auth_path: authPath };
567
+ }
568
+ if (!(await exists(authPath))) {
569
+ return { status: 'skipped', reason: 'auth_missing', auth_path: authPath };
570
+ }
571
+ const authText = await readText(authPath, '');
572
+ if (!authText.trim()) {
573
+ return { status: 'skipped', reason: 'auth_empty', auth_path: authPath };
574
+ }
575
+ if (!hasChatgptOAuthTokens(authText)) {
576
+ return { status: 'no_oauth_conflict', auth_path: authPath };
577
+ }
578
+ const envText = await readText(status.env_path, '');
579
+ const apiKey = parseCodexLbEnvKey(envText);
580
+ if (!apiKey) {
581
+ return { status: 'skipped', reason: 'missing_env_key', auth_path: authPath };
582
+ }
583
+ // Always back up the OAuth blob — even if reconciliation is opted out — so the data survives.
584
+ try {
585
+ await ensureDir(path.dirname(backupPath));
586
+ await writeTextAtomic(backupPath, authText);
587
+ await fsp.chmod(backupPath, 0o600).catch(() => {});
588
+ } catch (err) {
589
+ return { status: 'failed', reason: 'backup_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
590
+ }
591
+ if (process.env.SKS_CODEX_LB_NO_AUTH_RECONCILE === '1' && !opts.force) {
592
+ return {
593
+ status: 'backup_only',
594
+ reason: 'SKS_CODEX_LB_NO_AUTH_RECONCILE=1',
595
+ auth_path: authPath,
596
+ backup_path: backupPath
597
+ };
598
+ }
599
+ try {
600
+ const replacement = `${JSON.stringify({ auth_mode: 'apikey', key: apiKey }, null, 2)}\n`;
601
+ await writeTextAtomic(authPath, replacement);
602
+ await fsp.chmod(authPath, 0o600).catch(() => {});
603
+ } catch (err) {
604
+ return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
605
+ }
606
+ return {
607
+ status: 'reconciled',
608
+ auth_path: authPath,
609
+ backup_path: backupPath
610
+ };
611
+ }
612
+
613
+ // Expose the ChatGPT OAuth backup path so the CLI can surface it in status / release output.
614
+ export function codexLbChatgptBackupPath(home = process.env.HOME || os.homedir()) {
615
+ return codexAuthChatgptBackupPath(home);
616
+ }
617
+
618
+ // Remove a top-level TOML key (only above the first table header). Returns the original text
619
+ // unchanged when the key isn't present.
620
+ function removeTopLevelTomlString(text, key) {
621
+ const lines = String(text || '').split('\n');
622
+ const firstTable = lines.findIndex((x) => /^\s*\[.+\]\s*$/.test(x));
623
+ const end = firstTable === -1 ? lines.length : firstTable;
624
+ let removed = false;
625
+ for (let i = end - 1; i >= 0; i--) {
626
+ if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[i])) {
627
+ lines.splice(i, 1);
628
+ removed = true;
629
+ }
630
+ }
631
+ if (!removed) return text;
632
+ return lines.join('\n').replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n');
633
+ }
634
+
635
+ // Unselect codex-lb at the top-level model_provider setting. Leaves [model_providers.codex-lb]
636
+ // and the env file alone so the user can re-engage with `sks codex-lb repair`.
637
+ export async function unselectCodexLbProvider(opts = {}) {
638
+ const home = opts.home || process.env.HOME || os.homedir();
639
+ const configPath = opts.configPath || codexLbConfigPath(home);
640
+ const current = await readText(configPath, '');
641
+ if (!current.trim()) return { status: 'not_selected', reason: 'no_config', config_path: configPath };
642
+ if (!hasTopLevelCodexLbSelected(current)) return { status: 'not_selected', config_path: configPath };
643
+ try {
644
+ const next = ensureTrailingNewline(removeTopLevelTomlString(current, 'model_provider'));
645
+ await writeTextAtomic(configPath, next);
646
+ return { status: 'unselected', config_path: configPath };
647
+ } catch (err) {
648
+ return { status: 'failed', reason: 'write_failed', config_path: configPath, error: err.message };
649
+ }
650
+ }
651
+
652
+ // Reverse of reconcileCodexLbAuthConflict: restore the ChatGPT OAuth blob from the backup file
653
+ // so the user can return to the official ChatGPT account login. Also deselects codex-lb at the
654
+ // model_provider level by default so the restored OAuth blob actually wins; pass keepProvider
655
+ // to skip that.
656
+ //
657
+ // Options:
658
+ // home - HOME override (selftest)
659
+ // keepProvider - leave `model_provider = "codex-lb"` selected (default: deselect)
660
+ // deleteBackup - remove ~/.codex/auth.chatgpt-backup.json after a successful restore
661
+ // (default: false; keeping it makes the next reconcile cycle a no-op clobber risk)
662
+ // force - restore even if the current auth.json shape isn't recognized
663
+ export async function releaseCodexLbAuthHold(opts = {}) {
664
+ const home = opts.home || process.env.HOME || os.homedir();
665
+ const authPath = opts.authPath || codexAuthPath(home);
666
+ const backupPath = opts.backupPath || codexAuthChatgptBackupPath(home);
667
+ const configPath = opts.configPath || codexLbConfigPath(home);
668
+
669
+ const backupExists = await exists(backupPath);
670
+ const backupText = backupExists ? await readText(backupPath, '') : '';
671
+ if (!backupExists || !backupText.trim()) {
672
+ return {
673
+ status: 'no_backup',
674
+ auth_path: authPath,
675
+ backup_path: backupPath,
676
+ provider_unselected: false
677
+ };
678
+ }
679
+ if (!hasChatgptOAuthTokens(backupText)) {
680
+ return {
681
+ status: 'no_backup',
682
+ reason: 'backup_not_oauth',
683
+ auth_path: authPath,
684
+ backup_path: backupPath,
685
+ provider_unselected: false
686
+ };
687
+ }
688
+
689
+ const currentAuthText = await readText(authPath, '');
690
+ const trimmedCurrent = currentAuthText.trim();
691
+
692
+ // If auth.json already looks like ChatGPT OAuth (user re-logged in some other way), don't
693
+ // clobber it — but still honor the deselect request so the OAuth blob takes effect.
694
+ if (trimmedCurrent && hasChatgptOAuthTokens(currentAuthText) && !opts.force) {
695
+ let providerUnselected = false;
696
+ let providerError = null;
697
+ if (!opts.keepProvider) {
698
+ const unselected = await unselectCodexLbProvider({ ...opts, home, configPath });
699
+ if (unselected.status === 'unselected') providerUnselected = true;
700
+ else if (unselected.status === 'failed') providerError = unselected.error || unselected.reason || 'unselect_failed';
701
+ }
702
+ return {
703
+ status: 'already_chatgpt',
704
+ auth_path: authPath,
705
+ backup_path: backupPath,
706
+ provider_unselected: providerUnselected,
707
+ provider_error: providerError
708
+ };
709
+ }
710
+
711
+ // Refuse to clobber unfamiliar auth.json shapes unless forced. We expect either an empty file,
712
+ // the apikey shape we wrote during reconcile, or a stray `{"auth_mode":"browser"}` marker.
713
+ if (!opts.force && trimmedCurrent) {
714
+ const looksApikey = /"auth_mode"\s*:\s*"apikey"/.test(currentAuthText) && Boolean(parseCodexAuthApiKey(currentAuthText));
715
+ const looksBrowserMarker = /^\{\s*"auth_mode"\s*:\s*"browser"\s*\}\s*$/.test(currentAuthText);
716
+ if (!looksApikey && !looksBrowserMarker) {
717
+ return {
718
+ status: 'auth_in_use',
719
+ reason: 'unfamiliar_auth_json',
720
+ auth_path: authPath,
721
+ backup_path: backupPath,
722
+ provider_unselected: false
723
+ };
724
+ }
725
+ }
726
+
727
+ try {
728
+ await ensureDir(path.dirname(authPath));
729
+ const restored = backupText.endsWith('\n') ? backupText : `${backupText}\n`;
730
+ await writeTextAtomic(authPath, restored);
731
+ await fsp.chmod(authPath, 0o600).catch(() => {});
732
+ } catch (err) {
733
+ return {
734
+ status: 'failed',
735
+ reason: 'restore_failed',
736
+ auth_path: authPath,
737
+ backup_path: backupPath,
738
+ error: err.message,
739
+ provider_unselected: false
740
+ };
741
+ }
742
+
743
+ let backupRemoved = false;
744
+ if (opts.deleteBackup) {
745
+ try {
746
+ await fsp.rm(backupPath, { force: true });
747
+ backupRemoved = true;
748
+ } catch {
749
+ // Non-fatal: the restore already landed.
750
+ }
751
+ }
752
+
753
+ let providerUnselected = false;
754
+ let providerError = null;
755
+ if (!opts.keepProvider) {
756
+ const unselected = await unselectCodexLbProvider({ ...opts, home, configPath });
757
+ if (unselected.status === 'unselected') providerUnselected = true;
758
+ else if (unselected.status === 'failed') providerError = unselected.error || unselected.reason || 'unselect_failed';
759
+ }
760
+
761
+ return {
762
+ status: 'released',
763
+ auth_path: authPath,
764
+ backup_path: backupPath,
765
+ backup_removed: backupRemoved,
766
+ provider_unselected: providerUnselected,
767
+ provider_error: providerError
768
+ };
769
+ }
770
+
473
771
  export async function maybePromptCodexLbSetupForLaunch(args = [], opts = {}) {
474
772
  if (args.includes('--json') || args.includes('--skip-codex-lb') || process.env.SKS_SKIP_CODEX_LB_PROMPT === '1') return { status: 'skipped' };
475
773
  let status = await codexLbStatus(opts);
@@ -495,8 +793,34 @@ export async function maybePromptCodexLbSetupForLaunch(args = [], opts = {}) {
495
793
  if (codexLogin.status === 'synced') console.log('codex-lb auth synced with Codex CLI login cache.');
496
794
  const chainHealth = await checkCodexLbResponseChain(status, opts);
497
795
  if (!chainHealth.ok && chainHealth.chain_unhealthy) {
498
- console.log(`codex-lb response chain check failed (${chainHealth.status}); bypassing codex-lb for this launch.`);
499
- return { status: 'chain_unhealthy', ...status, ok: false, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth, bypass_codex_lb: true };
796
+ // `previous_response_not_found` is normal for stateless LB deployments that don't persist
797
+ // Responses across requests. The codex-lb provider still works fine only the chained
798
+ // health probe fails. Keep codex-lb active and just warn.
799
+ if (chainHealth.status === 'previous_response_not_found') {
800
+ console.log('codex-lb response chain check: previous_response_id not persisted by the load balancer (this is normal for stateless deployments). Keeping codex-lb active.');
801
+ return { status: 'present', ...status, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth };
802
+ }
803
+ // Hard chain failure (auth rejected, timeout, missing base URL, etc.). Don't silently
804
+ // demote a configured codex-lb to ChatGPT OAuth — surface the failure and let the user
805
+ // decide. Default keeps codex-lb (just press Enter).
806
+ console.log(`codex-lb response chain check failed (${chainHealth.status}${chainHealth.error ? `: ${chainHealth.error}` : ''}).`);
807
+ if (process.env.SKS_CODEX_LB_AUTOBYPASS === '1') {
808
+ console.log('SKS_CODEX_LB_AUTOBYPASS=1 set; bypassing codex-lb to ChatGPT OAuth for this launch.');
809
+ return { status: 'chain_unhealthy', ...status, ok: false, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth, bypass_codex_lb: true };
810
+ }
811
+ if (canAskYesNo()) {
812
+ const answer = (await askPostinstallQuestion('Use codex-lb anyway, or fall back to ChatGPT OAuth? [LB/oauth] ')).trim().toLowerCase();
813
+ if (/^(oauth|o|chatgpt|fall ?back|n|no|아니|아니요|ㄴ)$/.test(answer)) {
814
+ console.log('Falling back to ChatGPT OAuth for this launch. Re-enable codex-lb anytime with `sks codex-lb repair`.');
815
+ return { status: 'chain_unhealthy', ...status, ok: false, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth, bypass_codex_lb: true };
816
+ }
817
+ console.log('Keeping codex-lb active. To switch back to ChatGPT OAuth: `sks codex-lb release`.');
818
+ return { status: 'present', ...status, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth };
819
+ }
820
+ // Non-interactive context with no opt-out env var. The user explicitly configured codex-lb,
821
+ // so default to keeping it active rather than silently swapping providers.
822
+ console.log('Non-interactive launch + chain check failure. Keeping codex-lb active. Set SKS_CODEX_LB_AUTOBYPASS=1 to auto-bypass to ChatGPT OAuth.');
823
+ return { status: 'present', ...status, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth };
500
824
  }
501
825
  return { status: 'present', ...status, codex_environment: codexEnvironment, codex_login: codexLogin, chain_health: chainHealth };
502
826
  }
@@ -1240,7 +1564,10 @@ export async function selftestCodexLb(tmp) {
1240
1564
  const codexLbFakeBin = path.join(tmp, 'codex-lb-fake-bin');
1241
1565
  await ensureDir(codexLbFakeBin);
1242
1566
  const codexLbFakeCodex = path.join(codexLbFakeBin, 'codex');
1243
- await writeTextAtomic(codexLbFakeCodex, "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"codex-cli 99.0.0\"; exit 0; fi\nif [ \"$1\" = \"login\" ] && [ \"$2\" = \"status\" ]; then echo \"logged in with browser auth\"; exit 0; fi\nif [ \"$1\" = \"login\" ] && [ \"$2\" = \"--with-api-key\" ]; then read key; mkdir -p \"$HOME/.codex\"; printf '{\\\"auth_mode\\\":\\\"apikey\\\",\\\"key\\\":\\\"%s\\\"}\\n' \"$key\" > \"$HOME/.codex/auth.json\"; printf '%s\\n' \"$key\" >> \"$HOME/.codex/login-calls.log\"; exit 0; fi\necho \"fake codex unsupported\" >&2\nexit 1\n");
1567
+ // NOTE: printf format uses literal double-quotes inside single-quoted shell strings so the
1568
+ // fake login writes proper JSON in both bash and dash (where `\"` is a non-standard printf
1569
+ // escape that dash emits literally and bash collapses to `"`).
1570
+ await writeTextAtomic(codexLbFakeCodex, "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"codex-cli 99.0.0\"; exit 0; fi\nif [ \"$1\" = \"login\" ] && [ \"$2\" = \"status\" ]; then echo \"logged in with browser auth\"; exit 0; fi\nif [ \"$1\" = \"login\" ] && [ \"$2\" = \"--with-api-key\" ]; then read key; mkdir -p \"$HOME/.codex\"; printf '{\"auth_mode\":\"apikey\",\"key\":\"%s\"}\\n' \"$key\" > \"$HOME/.codex/auth.json\"; printf '%s\\n' \"$key\" >> \"$HOME/.codex/login-calls.log\"; exit 0; fi\necho \"fake codex unsupported\" >&2\nexit 1\n");
1244
1571
  await fsp.chmod(codexLbFakeCodex, 0o755);
1245
1572
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), 'model = "gpt-5.5"\nmodel_reasoning_effort = "low"\nservice_tier = "fast"\n\n[profiles.custom]\nmodel_reasoning_effort = "low"\n\n[notice]\nfast_default_opt_out = true\n\n[features]\ncodex_hooks = true\n');
1246
1573
  const codexLbEnvForSelftest = { HOME: codexLbHome, SKS_GLOBAL_ROOT: path.join(tmp, 'codex-lb-global'), PATH: `${codexLbFakeBin}${path.delimiter}${process.env.PATH || ''}`, SKS_SKIP_CODEX_LB_LAUNCH_ENV: '1' };
@@ -1353,6 +1680,88 @@ export async function selftestCodexLb(tmp) {
1353
1680
  const codexLbDoctorConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1354
1681
  if (!codexLbDoctorJson.repair?.codex_lb?.ok || !codexLbDoctorJson.repair.codex_lb.config_repaired || !codexLbDoctorJson.codex_lb?.ok || !codexLbDoctorAuth.includes('"auth_mode":"browser"') || codexLbDoctorAuth.includes('sk-test') || !hasTopLevelCodexLbSelected(codexLbDoctorConfig) || !codexLbDoctorConfig.includes('https://lb.example.test/backend-api/codex') || !hasCodexUnstableFeatureWarningSuppression(codexLbDoctorConfig)) throw new Error('selftest: doctor codex-lb');
1355
1682
  if (!codexLbDoctorConfig.includes('requires_openai_auth = true')) throw new Error('selftest: doctor codex-lb did not restore upstream-required requires_openai_auth');
1683
+ // codex-lb auth: ChatGPT OAuth ↔ codex-lb env_key conflict reconciliation.
1684
+ const oauthAuthJson = JSON.stringify({
1685
+ auth_mode: 'chatgpt',
1686
+ tokens: { id_token: 'oauth-id', access_token: 'oauth-access', refresh_token: 'oauth-refresh' },
1687
+ last_refresh: '2026-01-01T00:00:00Z'
1688
+ });
1689
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1690
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'), "export CODEX_LB_BASE_URL='https://lb.example.test/backend-api/codex'\nexport CODEX_LB_API_KEY='sk-test'\n");
1691
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), 'model_provider = "codex-lb"\n\n[model_providers.codex-lb]\nname = "OpenAI"\nbase_url = "https://lb.example.test/backend-api/codex"\nwire_api = "responses"\nenv_key = "CODEX_LB_API_KEY"\nsupports_websockets = true\nrequires_openai_auth = true\n');
1692
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1693
+ const codexLbReconcileRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1694
+ if (codexLbReconcileRepair.code !== 0) throw new Error(`selftest: codex-lb oauth reconcile repair exited ${codexLbReconcileRepair.code}: ${codexLbReconcileRepair.stderr}`);
1695
+ const codexLbReconcileJson = JSON.parse(codexLbReconcileRepair.stdout);
1696
+ const codexLbReconcileAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1697
+ const codexLbReconcileBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1698
+ if (codexLbReconcileJson.auth_reconcile?.status !== 'reconciled' || !codexLbReconcileAuth.includes('"auth_mode": "apikey"') || !codexLbReconcileAuth.includes('sk-test') || codexLbReconcileAuth.includes('oauth-id') || !codexLbReconcileBackup.includes('oauth-id') || !codexLbReconcileBackup.includes('oauth-refresh')) throw new Error('selftest: codex-lb oauth reconcile did not back up ChatGPT tokens and switch auth.json to apikey mode');
1699
+ // Opt-out path: SKS_CODEX_LB_NO_AUTH_RECONCILE=1 keeps auth.json untouched but still backs up the OAuth blob.
1700
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1701
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1702
+ const codexLbReconcileOptOutRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: { ...codexLbEnvForSelftest, SKS_CODEX_LB_NO_AUTH_RECONCILE: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1703
+ if (codexLbReconcileOptOutRepair.code !== 0) throw new Error(`selftest: codex-lb oauth reconcile opt-out repair exited ${codexLbReconcileOptOutRepair.code}: ${codexLbReconcileOptOutRepair.stderr}`);
1704
+ const codexLbReconcileOptOutJson = JSON.parse(codexLbReconcileOptOutRepair.stdout);
1705
+ const codexLbReconcileOptOutAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1706
+ const codexLbReconcileOptOutBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1707
+ if (codexLbReconcileOptOutJson.auth_reconcile?.status !== 'backup_only' || !codexLbReconcileOptOutAuth.includes('oauth-id') || !codexLbReconcileOptOutBackup.includes('oauth-id')) throw new Error('selftest: codex-lb oauth reconcile opt-out should back up but not rewrite auth.json');
1708
+ // codex-lb auth: release flow — restore ChatGPT OAuth from backup so the user can return to
1709
+ // the official ChatGPT account login. Default deselects model_provider; flags control whether
1710
+ // the provider stays selected and whether the backup file is removed after restore.
1711
+ const codexLbReleaseConfig = 'model_provider = "codex-lb"\n\n[model_providers.codex-lb]\nname = "OpenAI"\nbase_url = "https://lb.example.test/backend-api/codex"\nwire_api = "responses"\nenv_key = "CODEX_LB_API_KEY"\nsupports_websockets = true\nrequires_openai_auth = true\n';
1712
+ const codexLbReleaseEnv = "export CODEX_LB_BASE_URL='https://lb.example.test/backend-api/codex'\nexport CODEX_LB_API_KEY='sk-test'\n";
1713
+ const codexLbReleaseApikeyAuth = '{"auth_mode":"apikey","key":"sk-test"}\n';
1714
+ const codexLbReleaseOauthBackup = `${oauthAuthJson}\n`;
1715
+ // Happy path: deselect model_provider and preserve backup file.
1716
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1717
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1718
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'), codexLbReleaseEnv);
1719
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1720
+ const codexLbReleaseRun = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'release', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1721
+ if (codexLbReleaseRun.code !== 0) throw new Error(`selftest: codex-lb release exited ${codexLbReleaseRun.code}: ${codexLbReleaseRun.stderr}`);
1722
+ const codexLbReleaseJson = JSON.parse(codexLbReleaseRun.stdout);
1723
+ const codexLbReleaseAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1724
+ const codexLbReleaseBackupAfter = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1725
+ const codexLbReleaseConfigAfter = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1726
+ if (codexLbReleaseJson.status !== 'released' || codexLbReleaseJson.provider_unselected !== true || codexLbReleaseJson.backup_removed !== false || !codexLbReleaseAuth.includes('oauth-id') || !codexLbReleaseAuth.includes('oauth-refresh') || codexLbReleaseAuth.includes('apikey') || !codexLbReleaseBackupAfter.includes('oauth-id') || hasTopLevelCodexLbSelected(codexLbReleaseConfigAfter)) throw new Error('selftest: codex-lb release happy path did not restore OAuth, preserve backup, and deselect model_provider');
1727
+ // --keep-provider: restore auth.json but leave model_provider = "codex-lb" alone.
1728
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1729
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1730
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1731
+ const codexLbReleaseKeepRun = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'release', '--keep-provider', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1732
+ if (codexLbReleaseKeepRun.code !== 0) throw new Error(`selftest: codex-lb release --keep-provider exited ${codexLbReleaseKeepRun.code}: ${codexLbReleaseKeepRun.stderr}`);
1733
+ const codexLbReleaseKeepJson = JSON.parse(codexLbReleaseKeepRun.stdout);
1734
+ const codexLbReleaseKeepConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1735
+ if (codexLbReleaseKeepJson.status !== 'released' || codexLbReleaseKeepJson.provider_unselected !== false || !hasTopLevelCodexLbSelected(codexLbReleaseKeepConfig)) throw new Error('selftest: codex-lb release --keep-provider should leave model_provider = "codex-lb" intact');
1736
+ // --delete-backup: restore auth.json and remove the backup file.
1737
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1738
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1739
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1740
+ const codexLbReleaseDeleteRun = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'release', '--delete-backup', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1741
+ if (codexLbReleaseDeleteRun.code !== 0) throw new Error(`selftest: codex-lb release --delete-backup exited ${codexLbReleaseDeleteRun.code}: ${codexLbReleaseDeleteRun.stderr}`);
1742
+ const codexLbReleaseDeleteJson = JSON.parse(codexLbReleaseDeleteRun.stdout);
1743
+ const codexLbReleaseDeleteBackupExists = await exists(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1744
+ if (codexLbReleaseDeleteJson.status !== 'released' || codexLbReleaseDeleteJson.backup_removed !== true || codexLbReleaseDeleteBackupExists) throw new Error('selftest: codex-lb release --delete-backup should remove the backup file after restore');
1745
+ // No backup: release should refuse and exit 1.
1746
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1747
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1748
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1749
+ const codexLbReleaseMissingRun = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'release', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1750
+ const codexLbReleaseMissingJson = JSON.parse(codexLbReleaseMissingRun.stdout || '{}');
1751
+ const codexLbReleaseMissingAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1752
+ if (codexLbReleaseMissingRun.code === 0 || codexLbReleaseMissingJson.status !== 'no_backup' || !codexLbReleaseMissingAuth.includes('apikey')) throw new Error('selftest: codex-lb release with no backup should exit non-zero and report no_backup without touching auth.json');
1753
+ // unselect: flip model_provider off without touching auth.json or env file.
1754
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1755
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1756
+ const codexLbUnselectRun = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'unselect', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1757
+ if (codexLbUnselectRun.code !== 0) throw new Error(`selftest: codex-lb unselect exited ${codexLbUnselectRun.code}: ${codexLbUnselectRun.stderr}`);
1758
+ const codexLbUnselectJson = JSON.parse(codexLbUnselectRun.stdout);
1759
+ const codexLbUnselectConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1760
+ const codexLbUnselectAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1761
+ if (codexLbUnselectJson.status !== 'unselected' || hasTopLevelCodexLbSelected(codexLbUnselectConfig) || !codexLbUnselectConfig.includes('[model_providers.codex-lb]') || !codexLbUnselectAuth.includes('apikey')) throw new Error('selftest: codex-lb unselect should drop model_provider but preserve [model_providers.codex-lb] and auth.json');
1762
+ // Restore the doctor-test auth.json shape so downstream selftest assertions still hold.
1763
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"browser"}\n');
1764
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1356
1765
  const codexLbContext7Bin = path.join(tmp, 'codex-lb-context7-bin');
1357
1766
  await ensureDir(codexLbContext7Bin);
1358
1767
  await writeTextAtomic(path.join(codexLbContext7Bin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 99.0.0"; exit 0; fi\nif [ "$CODEX_LB_API_KEY" ]; then echo "context7 leaked CODEX_LB_API_KEY" >&2; exit 77; fi\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then echo ""; exit 0; fi\nif [ "$1" = "mcp" ] && [ "$2" = "add" ]; then echo "context7 added"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
@@ -1514,7 +1923,42 @@ export async function selftestCodexLb(tmp) {
1514
1923
  return new Response(JSON.stringify({ error: { type: 'invalid_request_error', code: 'previous_response_not_found', message: 'Previous response not found.', param: 'previous_response_id' } }), { status: 400, headers: { 'content-type': 'application/json' } });
1515
1924
  }
1516
1925
  });
1517
- if (nonInteractiveBrokenLaunch.status !== 'chain_unhealthy' || nonInteractiveBrokenLaunch.bypass_codex_lb !== true || nonInteractiveBrokenLaunch.chain_health?.status !== 'previous_response_not_found') throw new Error('selftest: non-interactive codex-lb launch path did not bypass on previous_response_not_found');
1926
+ if (nonInteractiveBrokenLaunch.status !== 'present' || nonInteractiveBrokenLaunch.bypass_codex_lb === true || nonInteractiveBrokenLaunch.chain_health?.status !== 'previous_response_not_found') throw new Error('selftest: previous_response_not_found should keep codex-lb active (stateless LB is normal), not silently bypass to ChatGPT OAuth');
1927
+ // Hard chain failure (e.g. 500) in non-interactive context should still keep codex-lb by default — the user explicitly configured it, so don't silently swap providers.
1928
+ const hardBrokenLaunchCalls = [];
1929
+ const hardBrokenLaunch = await maybePromptCodexLbSetupForLaunch([], {
1930
+ home: codexLbHome,
1931
+ apiKey: 'sk-test',
1932
+ codexBin: path.join(codexLbFakeBin, 'codex'),
1933
+ syncLaunchEnv: false,
1934
+ timeoutMs: 1000,
1935
+ fetch: async (_url, init) => {
1936
+ hardBrokenLaunchCalls.push({ body: JSON.parse(init.body) });
1937
+ if (!hardBrokenLaunchCalls[hardBrokenLaunchCalls.length - 1].body.previous_response_id) return new Response(JSON.stringify({ id: 'resp_hardbroken_first' }), { status: 200, headers: { 'content-type': 'application/json' } });
1938
+ return new Response(JSON.stringify({ error: { type: 'server_error', code: 'internal_error', message: 'simulated upstream failure' } }), { status: 500, headers: { 'content-type': 'application/json' } });
1939
+ }
1940
+ });
1941
+ if (hardBrokenLaunch.status !== 'present' || hardBrokenLaunch.bypass_codex_lb === true || hardBrokenLaunch.chain_health?.status !== 'second_request_failed') throw new Error('selftest: hard codex-lb chain failure in non-interactive launch should default to keeping codex-lb active, not silently bypass');
1942
+ // SKS_CODEX_LB_AUTOBYPASS=1 restores the old silent-bypass behavior for CI/automation.
1943
+ process.env.SKS_CODEX_LB_AUTOBYPASS = '1';
1944
+ let autobypassLaunch;
1945
+ try {
1946
+ autobypassLaunch = await maybePromptCodexLbSetupForLaunch([], {
1947
+ home: codexLbHome,
1948
+ apiKey: 'sk-test',
1949
+ codexBin: path.join(codexLbFakeBin, 'codex'),
1950
+ syncLaunchEnv: false,
1951
+ timeoutMs: 1000,
1952
+ fetch: async (_url, init) => {
1953
+ const body = JSON.parse(init.body);
1954
+ if (!body.previous_response_id) return new Response(JSON.stringify({ id: 'resp_autobypass_first' }), { status: 200, headers: { 'content-type': 'application/json' } });
1955
+ return new Response(JSON.stringify({ error: { type: 'server_error', code: 'internal_error', message: 'simulated upstream failure' } }), { status: 500, headers: { 'content-type': 'application/json' } });
1956
+ }
1957
+ });
1958
+ } finally {
1959
+ delete process.env.SKS_CODEX_LB_AUTOBYPASS;
1960
+ }
1961
+ if (autobypassLaunch.status !== 'chain_unhealthy' || autobypassLaunch.bypass_codex_lb !== true || autobypassLaunch.chain_health?.status !== 'second_request_failed') throw new Error('selftest: SKS_CODEX_LB_AUTOBYPASS=1 should bypass codex-lb on hard chain failure');
1518
1962
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), 'model = "gpt-5.5"\nservice_tier = "fast"\n');
1519
1963
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'), "export CODEX_LB_BASE_URL='https://lb.example.test/backend-api/codex'\nexport CODEX_LB_API_KEY='sk-test'\n");
1520
1964
  const missingProviderLaunchCalls = [];
package/src/cli/main.mjs CHANGED
@@ -79,7 +79,7 @@ import { OPENCLAW_SKILL_NAME, installOpenClawSkill } from '../core/openclaw.mjs'
79
79
  import { buildTmuxLaunchPlan, buildTmuxOpenArgs, codexLaunchCommand, createTmuxSession, defaultCodexLaunchArgs, isTmuxShellSession, runTmuxLaunchPlanSyntaxCheck, shouldAutoAttachTmux, sksAsciiLogo, tmuxReadiness, tmuxStatusKind, defaultTmuxSessionName, formatTmuxBanner, launchMadTmuxUi, launchTmuxTeamView, launchTmuxUi, platformTmuxInstallHint, reconcileTmuxTeamCockpit, runTmuxStatus, sanitizeTmuxSessionName, sweepCodexLbTmuxSessions, sweepTmuxTeamSurfaces, teamLaneStyle } from '../core/tmux-ui.mjs';
80
80
  import { autoReviewProfileName, autoReviewStatus, autoReviewSummary, enableAutoReview, disableAutoReview, enableMadHighProfile, madHighProfileName } from '../core/auto-review.mjs';
81
81
  import { context7Command } from './context7-command.mjs';
82
- import { askPostinstallQuestion, checkCodexLbResponseChain, checkContext7, checkRequiredSkills, codexLbStatus, configureCodexLb, ensureCodexCliTool, ensureGlobalCodexFastModeDuringInstall, ensureGlobalCodexSkillsDuringInstall, ensureProjectContext7Config, ensureRelatedCliTools, ensureSksCommandDuringInstall, ensureTmuxCliTool, globalCodexSkillsRoot, maybePromptCodexLbSetupForLaunch, maybePromptCodexUpdateForLaunch, postinstall, postinstallBootstrapDecision, repairCodexLbAuth, selftestCodexLb, shouldAutoApproveInstall } from './install-helpers.mjs';
82
+ import { askPostinstallQuestion, checkCodexLbResponseChain, checkContext7, checkRequiredSkills, codexLbChatgptBackupPath, codexLbStatus, configureCodexLb, ensureCodexCliTool, ensureGlobalCodexFastModeDuringInstall, ensureGlobalCodexSkillsDuringInstall, ensureProjectContext7Config, ensureRelatedCliTools, ensureSksCommandDuringInstall, ensureTmuxCliTool, globalCodexSkillsRoot, maybePromptCodexLbSetupForLaunch, maybePromptCodexUpdateForLaunch, postinstall, postinstallBootstrapDecision, releaseCodexLbAuthHold, repairCodexLbAuth, selftestCodexLb, shouldAutoApproveInstall, unselectCodexLbProvider } from './install-helpers.mjs';
83
83
  import { buildTeamPlan, codeStructureCommand, dbCommand, defaultBeta, defaultVGraph, evalCommand, gcCommand, goalCommand, gxCommand, harnessCommand, hproofCommand, madHighCommand as runMadHighCommand, memoryCommand, migrateWikiContextPack, parseTeamCreateArgs, perfCommand, profileCommand, projectWikiClaims, proofFieldCommand, qaLoopCommand, quickstartCommand, researchCommand, skillDreamCommand, statsCommand, team, teamWorkflowMarkdown, validateArtifactsCommand, wikiCommand, wikiVoxelRowCount, writeWikiContextPack } from './maintenance-commands.mjs';
84
84
  import { openClawCommand } from './openclaw-command.mjs';
85
85
  import { recallPulseCommand } from './recallpulse-command.mjs';
@@ -188,8 +188,8 @@ Usage:
188
188
  sks bootstrap [--install-scope global|project] [--local-only] [--json]
189
189
  sks deps check|install [tmux|codex|context7|all] [--yes] [--json]
190
190
  sks codex-app
191
- sks codex-lb status|health|repair|setup --host <domain> --api-key <key>
192
- sks auth status|health|repair|setup --host <domain> --api-key <key>
191
+ sks codex-lb status|health|repair|release|unselect|setup --host <domain> --api-key <key>
192
+ sks auth status|health|repair|release|unselect|setup --host <domain> --api-key <key>
193
193
  sks openclaw install|path|print [--dir path] [--force] [--json]
194
194
  sks --mad [--high]
195
195
  sks auto-review status|enable|start [--high]
@@ -1141,7 +1141,9 @@ async function codexLbCommand(action = 'status', args = []) {
1141
1141
  const json = flag(args, '--json');
1142
1142
  if (sub === 'status' || sub === 'check') {
1143
1143
  const status = await codexLbStatus();
1144
- if (json) return console.log(JSON.stringify(status, null, 2));
1144
+ const backupPath = codexLbChatgptBackupPath();
1145
+ const backupPresent = await exists(backupPath);
1146
+ if (json) return console.log(JSON.stringify({ ...status, chatgpt_backup_present: backupPresent, chatgpt_backup_path: backupPath }, null, 2));
1145
1147
  console.log('SKS codex-lb\n');
1146
1148
  console.log(`Configured: ${status.ok ? 'yes' : 'no'}`);
1147
1149
  console.log(`Selected: ${status.selected ? 'yes' : 'no'}`);
@@ -1149,10 +1151,71 @@ async function codexLbCommand(action = 'status', args = []) {
1149
1151
  console.log(`Codex App auth: ${status.provider_requires_openai_auth ? 'yes' : 'missing'}`);
1150
1152
  console.log(`Env file: ${status.env_file ? status.env_path : 'missing'}`);
1151
1153
  if (status.base_url) console.log(`Base URL: ${status.base_url}`);
1154
+ console.log(`ChatGPT backup: ${backupPresent ? `yes (${backupPath})` : 'no'}`);
1152
1155
  if (status.ok && !status.selected) console.log('\nRun: sks codex-lb repair to activate codex-lb for Codex App.');
1153
1156
  else if (!status.ok && status.base_url && status.env_key_configured) console.log('\nRun: sks codex-lb repair to restore the upstream codex-lb provider block.');
1154
1157
  else if (!status.ok) console.log('\nRun: sks codex-lb setup --host <domain> --api-key <key>');
1155
1158
  else console.log('\nRepair provider auth: sks codex-lb repair');
1159
+ if (backupPresent) console.log('Switch back to ChatGPT OAuth login: sks codex-lb release');
1160
+ return;
1161
+ }
1162
+ if (sub === 'release') {
1163
+ const result = await releaseCodexLbAuthHold({
1164
+ keepProvider: flag(args, '--keep-provider'),
1165
+ deleteBackup: flag(args, '--delete-backup'),
1166
+ force: flag(args, '--force')
1167
+ });
1168
+ if (result.status === 'no_backup' || result.status === 'auth_in_use' || result.status === 'failed') process.exitCode = 1;
1169
+ if (json) return console.log(JSON.stringify(result, null, 2));
1170
+ if (result.status === 'released') {
1171
+ console.log('codex-lb auth released: ChatGPT OAuth blob restored.');
1172
+ console.log(`Auth: ${result.auth_path}`);
1173
+ console.log(`Backup: ${result.backup_removed ? 'removed' : result.backup_path}`);
1174
+ console.log(`Provider unselected: ${result.provider_unselected ? 'yes' : 'no'}`);
1175
+ if (result.provider_error) console.log(`Provider unselect warning: ${result.provider_error}`);
1176
+ console.log('\nLaunch Codex App / `codex` and complete the ChatGPT browser login if prompted.');
1177
+ return;
1178
+ }
1179
+ if (result.status === 'already_chatgpt') {
1180
+ console.log('codex-lb auth release: auth.json already carries ChatGPT OAuth tokens — nothing to restore.');
1181
+ console.log(`Auth: ${result.auth_path}`);
1182
+ console.log(`Backup: ${result.backup_path}`);
1183
+ console.log(`Provider unselected: ${result.provider_unselected ? 'yes' : 'no'}`);
1184
+ if (result.provider_error) console.log(`Provider unselect warning: ${result.provider_error}`);
1185
+ return;
1186
+ }
1187
+ if (result.status === 'no_backup') {
1188
+ console.error(`codex-lb auth release: no ChatGPT OAuth backup found at ${result.backup_path}.`);
1189
+ if (result.reason === 'backup_not_oauth') console.error('The backup file is present but does not contain a ChatGPT OAuth token blob — refusing to clobber auth.json.');
1190
+ else console.error('Run `sks codex-lb repair` after a fresh ChatGPT login to recreate a backup, or `sks codex-lb unselect` to leave codex-lb off without touching auth.json.');
1191
+ process.exitCode = 1;
1192
+ return;
1193
+ }
1194
+ if (result.status === 'auth_in_use') {
1195
+ console.error(`codex-lb auth release refused: ${result.auth_path} does not look like the codex-lb apikey shape. Re-run with --force to overwrite, or back up auth.json yourself first.`);
1196
+ process.exitCode = 1;
1197
+ return;
1198
+ }
1199
+ console.error(`codex-lb auth release failed: ${result.status}${result.error ? `: ${result.error}` : ''}`);
1200
+ process.exitCode = 1;
1201
+ return;
1202
+ }
1203
+ if (sub === 'unselect') {
1204
+ const result = await unselectCodexLbProvider();
1205
+ if (result.status === 'failed') process.exitCode = 1;
1206
+ if (json) return console.log(JSON.stringify(result, null, 2));
1207
+ if (result.status === 'unselected') {
1208
+ console.log('codex-lb unselected. Codex CLI/App will fall back to the default OpenAI provider.');
1209
+ console.log(`Config: ${result.config_path}`);
1210
+ console.log('Re-engage codex-lb with: sks codex-lb repair');
1211
+ return;
1212
+ }
1213
+ if (result.status === 'not_selected') {
1214
+ console.log('codex-lb is not selected — nothing to do.');
1215
+ return;
1216
+ }
1217
+ console.error(`codex-lb unselect failed: ${result.status}${result.error ? `: ${result.error}` : ''}`);
1218
+ process.exitCode = 1;
1156
1219
  return;
1157
1220
  }
1158
1221
  if (sub === 'health' || sub === 'verify-chain' || sub === 'chain') {
@@ -1205,7 +1268,7 @@ async function codexLbCommand(action = 'status', args = []) {
1205
1268
  console.log(`Key env: ${result.env_path}`);
1206
1269
  return;
1207
1270
  }
1208
- console.error('Usage: sks codex-lb status|health|repair|setup --host <domain> --api-key <key> [--json]');
1271
+ console.error('Usage: sks codex-lb status|health|repair|release [--keep-provider] [--delete-backup] [--force]|unselect|setup --host <domain> --api-key <key> [--json]');
1209
1272
  process.exitCode = 1;
1210
1273
  }
1211
1274
 
@@ -2007,6 +2070,11 @@ function readFlagValue(args, name, fallback) {
2007
2070
  }
2008
2071
 
2009
2072
  async function selftest() {
2073
+ // Force non-interactive mode for the entire selftest so any in-process call that hits
2074
+ // canAskYesNo() (codex-lb provider-restore prompt, chain-failure prompt, etc.) takes the
2075
+ // non-interactive fallback path instead of bubbling a live readline prompt up to the
2076
+ // user's terminal (e.g. during `npm publish` -> prepublishOnly -> release:check -> selftest).
2077
+ process.env.CI = 'true';
2010
2078
  const tmp = tmpdir();
2011
2079
  process.chdir(tmp);
2012
2080
  await initProject(tmp, {});
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.9.2';
8
+ export const PACKAGE_VERSION = '0.9.6';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11