sneakoscope 0.9.8 → 0.9.9

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
@@ -210,6 +210,8 @@ Flags:
210
210
 
211
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
212
 
213
+ If Codex App shows `access token could not be refreshed` after codex-lb setup or status checks, recover the ChatGPT OAuth side without discarding codex-lb: run `sks codex-lb status`, then `sks codex-lb repair`. Repair restores a ChatGPT OAuth backup when one exists while keeping `model_provider = "codex-lb"` selected and the codex-lb key in `CODEX_LB_API_KEY`. If no OAuth backup exists, sign in again in Codex App/CLI, then rerun `sks codex-lb repair`. Use `sks codex-lb release` only when you want to switch fully away from codex-lb.
214
+
213
215
  If you only want to stop routing through codex-lb without touching `auth.json`, use the lighter `sks codex-lb unselect` instead:
214
216
 
215
217
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.9.8",
4
+ "version": "0.9.9",
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",
@@ -88,8 +88,12 @@ async function reportPostinstallCodexLbAuth() {
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
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.`);
91
+ if (reconcile?.status === 'oauth_preserved') {
92
+ console.log(`codex-lb auth: ChatGPT OAuth preserved for Codex App; codex-lb key stays in env_key (OAuth backup at ${reconcile.backup_path}).`);
93
+ } else if (reconcile?.status === 'oauth_restored') {
94
+ console.log(`codex-lb auth: restored ChatGPT OAuth from ${reconcile.backup_path} while keeping codex-lb selected.`);
95
+ } else if (reconcile?.status === 'apikey_forced') {
96
+ console.log(`codex-lb auth: forced API-key auth.json for CLI-only use (OAuth backup at ${reconcile.backup_path}).`);
93
97
  } else if (reconcile?.status === 'backup_only') {
94
98
  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
99
  } else if (reconcile?.status === 'failed') {
@@ -263,6 +267,9 @@ export async function configureCodexLb(opts = {}) {
263
267
  await fsp.chmod(envPath, 0o600).catch(() => {});
264
268
  const codexEnvironment = await syncCodexLbProviderEnvironment({ env_path: envPath, base_url: baseUrl }, { ...opts, home });
265
269
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home, force: true });
270
+ const codexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
271
+ const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, home, status: codexLb }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
272
+ const finalCodexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
266
273
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
267
274
  return {
268
275
  ok,
@@ -271,9 +278,11 @@ export async function configureCodexLb(opts = {}) {
271
278
  env_path: envPath,
272
279
  base_url: baseUrl,
273
280
  env_key: 'CODEX_LB_API_KEY',
281
+ auth_reconcile: authReconcile,
282
+ codex_lb: finalCodexLb,
274
283
  codex_environment: codexEnvironment,
275
284
  codex_login: codexLogin,
276
- error: codexEnvironment.error || codexLogin.error || null
285
+ error: authReconcile.error || codexEnvironment.error || codexLogin.error || null
277
286
  };
278
287
  }
279
288
 
@@ -284,6 +293,9 @@ export async function codexLbStatus(opts = {}) {
284
293
  const config = await readText(configPath, '');
285
294
  const envExists = await exists(envPath);
286
295
  const envText = envExists ? await readText(envPath, '') : '';
296
+ const authPath = opts.authPath || codexAuthPath(home);
297
+ const authText = await readText(authPath, '');
298
+ const authMode = codexAuthModeSummary(authText);
287
299
  const envKeyConfigured = Boolean(parseCodexLbEnvKey(envText));
288
300
  const providerConfigured = /\[model_providers\.codex-lb\]/.test(config);
289
301
  const selected = hasTopLevelCodexLbSelected(config);
@@ -299,10 +311,52 @@ export async function codexLbStatus(opts = {}) {
299
311
  env_file: envExists,
300
312
  env_key_configured: envKeyConfigured,
301
313
  env_base_url_configured: Boolean(parseCodexLbEnvBaseUrl(envText)),
302
- base_url: baseUrl
314
+ base_url: baseUrl,
315
+ auth_path: authPath,
316
+ auth_mode: authMode.mode,
317
+ auth_usable_for_codex_app: authMode.codex_app_usable,
318
+ auth_summary: authMode.summary
303
319
  };
304
320
  }
305
321
 
322
+ export function formatCodexLbStatusText(status = {}, opts = {}) {
323
+ const backupPresent = Boolean(opts.backupPresent);
324
+ const backupPath = opts.backupPath || '';
325
+ const lines = [
326
+ 'SKS codex-lb',
327
+ '',
328
+ `Configured: ${status.ok ? 'yes' : 'no'}`,
329
+ `Selected: ${status.selected ? 'yes' : 'no'}`,
330
+ `Provider: ${status.provider_configured ? 'yes' : 'no'}`,
331
+ `Provider requires OpenAI auth: ${status.provider_requires_openai_auth ? 'yes' : 'missing'}`,
332
+ `Codex App auth: ${status.auth_usable_for_codex_app ? 'ok' : 'needs sign-in/repair'} (${status.auth_mode || 'unknown'})`
333
+ ];
334
+ if (status.auth_summary) lines.push(`Auth detail: ${status.auth_summary}`);
335
+ lines.push(`Env file: ${status.env_file ? status.env_path : 'missing'}`);
336
+ if (status.base_url) lines.push(`Base URL: ${status.base_url}`);
337
+ lines.push(`ChatGPT backup: ${backupPresent ? `yes (${backupPath})` : 'no'}`);
338
+ if (status.ok && !status.auth_usable_for_codex_app && backupPresent) lines.push('', 'Run: sks codex-lb repair to restore the ChatGPT OAuth backup while keeping codex-lb selected.');
339
+ else if (status.ok && !status.auth_usable_for_codex_app) lines.push('', 'Sign in to Codex App/CLI again, then run: sks codex-lb repair');
340
+ else if (status.ok && !status.selected) lines.push('', 'Run: sks codex-lb repair to activate codex-lb for Codex App.');
341
+ else if (!status.ok && status.base_url && status.env_key_configured) lines.push('', 'Run: sks codex-lb repair to restore the upstream codex-lb provider block.');
342
+ else if (!status.ok) lines.push('', 'Run: sks codex-lb setup --host <domain> --api-key <key>');
343
+ else lines.push('', 'Repair provider auth: sks codex-lb repair');
344
+ if (backupPresent) lines.push('Switch fully away from codex-lb: sks codex-lb release');
345
+ return `${lines.join('\n')}\n`;
346
+ }
347
+
348
+ export function formatCodexLbRepairResultText(result = {}) {
349
+ const lines = [
350
+ 'codex-lb provider auth repaired for Codex CLI/App environment.',
351
+ `Config: ${result.config_path}`,
352
+ `Key env: ${result.env_path}`
353
+ ];
354
+ if (result.auth_reconcile?.status === 'oauth_restored') lines.push(`Codex App auth: ChatGPT OAuth restored from ${result.auth_reconcile.backup_path}.`);
355
+ else if (result.auth_reconcile?.status === 'oauth_preserved') lines.push('Codex App auth: ChatGPT OAuth preserved; codex-lb will use CODEX_LB_API_KEY from env_key.');
356
+ else if (result.auth_reconcile?.status === 'apikey_auth_active') lines.push('Codex App auth: API-key auth.json is still active. Sign in again if the App asks for ChatGPT OAuth.');
357
+ return `${lines.join('\n')}\n`;
358
+ }
359
+
306
360
  function codexLbResponsesEndpoint(baseUrl = '') {
307
361
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
308
362
  if (!base) return '';
@@ -313,6 +367,77 @@ function codexLbChainCheckEnabled(env = process.env) {
313
367
  return env.SKS_CODEX_LB_CHAIN_CHECK !== '0' && env.SKS_SKIP_CODEX_LB_CHAIN_CHECK !== '1';
314
368
  }
315
369
 
370
+ function codexLbChainCachePath(home = process.env.HOME || os.homedir()) {
371
+ return path.join(home, '.codex', 'sks-codex-lb-chain-health.json');
372
+ }
373
+
374
+ function codexLbChainCacheTtlMs(status = '', env = process.env) {
375
+ const hardFailure = Boolean(status && !['chain_ok', 'previous_response_not_found'].includes(status));
376
+ const key = hardFailure ? 'SKS_CODEX_LB_CHAIN_CHECK_FAILURE_CACHE_TTL_MS' : 'SKS_CODEX_LB_CHAIN_CHECK_CACHE_TTL_MS';
377
+ const fallback = hardFailure ? 30 * 1000 : 5 * 60 * 1000;
378
+ const raw = env[key] ?? env.SKS_CODEX_LB_CHAIN_CHECK_CACHE_TTL_MS;
379
+ if (raw === undefined || raw === '') return fallback;
380
+ const parsed = Number(raw);
381
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
382
+ }
383
+
384
+ function codexLbChainCacheEnabled(opts = {}, env = process.env) {
385
+ if (opts.force || opts.cache === false) return false;
386
+ if (opts.fetch) return false;
387
+ if (env.SKS_CODEX_LB_CHAIN_CHECK_CACHE === '0') return false;
388
+ return true;
389
+ }
390
+
391
+ async function readCodexLbChainCache({ endpoint, home, opts = {}, env = process.env } = {}) {
392
+ if (!endpoint || !codexLbChainCacheEnabled(opts, env)) return null;
393
+ const cachePath = opts.cachePath || codexLbChainCachePath(home || env.HOME || os.homedir());
394
+ const text = await readText(cachePath, '');
395
+ if (!text.trim()) return null;
396
+ try {
397
+ const parsed = JSON.parse(text);
398
+ if (parsed?.schema !== 'sks.codex-lb-chain-health.v1' || parsed.endpoint !== endpoint || !parsed.result?.status) return null;
399
+ const now = typeof opts.now === 'function' ? opts.now() : Date.now();
400
+ const checkedAt = Number(parsed.checked_at_ms || 0);
401
+ const ttlMs = codexLbChainCacheTtlMs(parsed.result.status, env);
402
+ if (!checkedAt || ttlMs <= 0 || now - checkedAt > ttlMs) return null;
403
+ return {
404
+ ...parsed.result,
405
+ endpoint,
406
+ cached: true,
407
+ cache_path: cachePath,
408
+ cache_age_ms: Math.max(0, now - checkedAt)
409
+ };
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ async function writeCodexLbChainCache(result = {}, { endpoint, home, opts = {}, env = process.env } = {}) {
416
+ if (!endpoint || !result.status || !codexLbChainCacheEnabled(opts, env)) return result;
417
+ const cachePath = opts.cachePath || codexLbChainCachePath(home || env.HOME || os.homedir());
418
+ const now = typeof opts.now === 'function' ? opts.now() : Date.now();
419
+ const cacheResult = {
420
+ ok: Boolean(result.ok),
421
+ status: result.status,
422
+ chain_unhealthy: result.chain_unhealthy === true,
423
+ http_status: result.http_status || null,
424
+ error: result.error || null
425
+ };
426
+ try {
427
+ await ensureDir(path.dirname(cachePath));
428
+ await writeTextAtomic(cachePath, `${JSON.stringify({
429
+ schema: 'sks.codex-lb-chain-health.v1',
430
+ endpoint,
431
+ checked_at_ms: now,
432
+ result: cacheResult
433
+ }, null, 2)}\n`);
434
+ await fsp.chmod(cachePath, 0o600).catch(() => {});
435
+ } catch {
436
+ // Cache writes are a launch optimization only; never block codex-lb startup.
437
+ }
438
+ return result;
439
+ }
440
+
316
441
  function isPreviousResponseNotFound(payload = {}) {
317
442
  const error = payload?.error || payload?.response?.error || payload;
318
443
  const text = typeof error === 'string'
@@ -382,8 +507,11 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
382
507
  if (!codexLbChainCheckEnabled(env) && !opts.force) return { ok: true, status: 'skipped', skipped: true, reason: 'SKS_CODEX_LB_CHAIN_CHECK=0' };
383
508
  const endpoint = codexLbResponsesEndpoint(opts.baseUrl || status.base_url);
384
509
  if (!endpoint) return { ok: false, status: 'missing_base_url', chain_unhealthy: true };
385
- const apiKey = opts.apiKey || parseCodexLbEnvKey(await readText(opts.envPath || status.env_path || codexLbEnvPath(opts.home || env.HOME || os.homedir()), ''));
510
+ const home = opts.home || env.HOME || os.homedir();
511
+ const apiKey = opts.apiKey || parseCodexLbEnvKey(await readText(opts.envPath || status.env_path || codexLbEnvPath(home), ''));
386
512
  if (!apiKey) return { ok: false, status: 'missing_env_key', chain_unhealthy: true };
513
+ const cached = await readCodexLbChainCache({ endpoint, home, opts, env });
514
+ if (cached) return cached;
387
515
  const fetchImpl = opts.fetch || globalThis.fetch;
388
516
  if (typeof fetchImpl !== 'function') return { ok: true, status: 'skipped', skipped: true, reason: 'fetch unavailable' };
389
517
  const model = opts.model || env.SKS_CODEX_MODEL || 'gpt-5.5';
@@ -400,19 +528,19 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
400
528
  };
401
529
  const first = await fetchCodexLbResponse(fetchImpl, endpoint, apiKey, baseBody, timeoutMs);
402
530
  if (!first.ok || !first.response_id) {
403
- return {
531
+ return writeCodexLbChainCache({
404
532
  ok: false,
405
533
  status: first.ok ? 'missing_response_id' : 'first_request_failed',
406
534
  chain_unhealthy: true,
407
535
  endpoint,
408
536
  http_status: first.status,
409
537
  error: redactSecretText(first.error_payload?.error?.message || first.error_payload?.response?.error?.message || first.text || 'codex-lb first Responses request failed', [apiKey])
410
- };
538
+ }, { endpoint, home, opts, env });
411
539
  }
412
540
  const second = await fetchCodexLbResponse(fetchImpl, endpoint, apiKey, { ...baseBody, previous_response_id: first.response_id }, timeoutMs);
413
- if (second.ok) return { ok: true, status: 'chain_ok', endpoint, response_id: first.response_id, chained_response_id: second.response_id || null, http_status: second.status };
541
+ if (second.ok) return writeCodexLbChainCache({ ok: true, status: 'chain_ok', endpoint, response_id: first.response_id, chained_response_id: second.response_id || null, http_status: second.status }, { endpoint, home, opts, env });
414
542
  const previousMissing = isPreviousResponseNotFound(second.error_payload || second.json || second.text);
415
- return {
543
+ return writeCodexLbChainCache({
416
544
  ok: false,
417
545
  status: previousMissing ? 'previous_response_not_found' : 'second_request_failed',
418
546
  chain_unhealthy: true,
@@ -420,7 +548,7 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
420
548
  response_id: first.response_id,
421
549
  http_status: second.status,
422
550
  error: redactSecretText(second.error_payload?.error?.message || second.error_payload?.response?.error?.message || second.text || 'codex-lb chained Responses request failed', [apiKey])
423
- };
551
+ }, { endpoint, home, opts, env });
424
552
  }
425
553
 
426
554
  function hasTopLevelCodexLbSelected(text = '') {
@@ -473,6 +601,7 @@ export async function repairCodexLbAuth(opts = {}) {
473
601
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
474
602
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
475
603
  const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
604
+ const finalStatus = await codexLbStatus(opts);
476
605
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
477
606
  return {
478
607
  ok,
@@ -484,7 +613,7 @@ export async function repairCodexLbAuth(opts = {}) {
484
613
  legacy_auth_migrated: legacyAuthMigrated,
485
614
  legacy_auth_path: legacyAuthPath,
486
615
  auth_reconcile: authReconcile,
487
- codex_lb: status,
616
+ codex_lb: finalStatus,
488
617
  codex_environment: codexEnvironment,
489
618
  codex_login: codexLogin
490
619
  };
@@ -504,6 +633,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
504
633
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
505
634
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
506
635
  const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
636
+ const finalStatus = await codexLbStatus(opts);
507
637
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
508
638
  return {
509
639
  ok,
@@ -511,7 +641,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
511
641
  config_path: status.config_path,
512
642
  env_path: status.env_path,
513
643
  base_url: status.base_url,
514
- codex_lb: status,
644
+ codex_lb: finalStatus,
515
645
  codex_environment: codexEnvironment,
516
646
  codex_login: codexLogin,
517
647
  auth_reconcile: authReconcile,
@@ -565,6 +695,19 @@ function parseCodexAuthApiKey(text = '') {
565
695
  }
566
696
  }
567
697
 
698
+ function codexAuthModeSummary(text = '') {
699
+ const raw = String(text || '').trim();
700
+ if (!raw) return { mode: 'missing', codex_app_usable: false, summary: 'missing auth.json' };
701
+ if (hasChatgptOAuthTokens(raw)) return { mode: 'chatgpt_oauth', codex_app_usable: true, summary: 'ChatGPT OAuth token blob present' };
702
+ const apiKey = parseCodexAuthApiKey(raw);
703
+ if (apiKey) return { mode: 'apikey', codex_app_usable: false, summary: 'API-key auth.json; Codex App may require ChatGPT sign-in for requires_openai_auth providers' };
704
+ try {
705
+ const parsed = JSON.parse(raw);
706
+ if (parsed?.auth_mode === 'browser') return { mode: 'browser_marker', codex_app_usable: false, summary: 'browser auth marker without refresh tokens' };
707
+ } catch {}
708
+ return { mode: 'unknown', codex_app_usable: false, summary: 'unrecognized auth.json shape' };
709
+ }
710
+
568
711
  // Migrate auth.json from legacy {"auth_mode":"apikey","key":"..."} to the codex 0.130.0+
569
712
  // format {"auth_mode":"apikey","OPENAI_API_KEY":"..."}. Safe: preserves key value, only renames field.
570
713
  async function migrateCodexAuthKeyFormat(opts = {}) {
@@ -587,11 +730,10 @@ async function migrateCodexAuthKeyFormat(opts = {}) {
587
730
  }
588
731
  }
589
732
 
590
- // When codex-lb is selected with env_key auth AND auth.json also carries a real ChatGPT OAuth
591
- // token blob, Codex CLI/App can pick the OAuth bearer over the env_key bearer and fail against
592
- // the load balancer. We back the OAuth blob up to ~/.codex/auth.chatgpt-backup.json and replace
593
- // auth.json with an apikey-mode payload that matches the stored CODEX_LB_API_KEY.
594
- // Opt out with SKS_CODEX_LB_NO_AUTH_RECONCILE=1 (the backup is still produced so nothing is lost).
733
+ // Codex App needs a refreshable ChatGPT OAuth blob when a provider declares
734
+ // requires_openai_auth=true. For codex-lb, the proxy key belongs in env_key
735
+ // (CODEX_LB_API_KEY), so SKS preserves or restores OAuth by default and only
736
+ // writes apikey auth.json when explicitly requested for CLI-only legacy use.
595
737
  export async function reconcileCodexLbAuthConflict(opts = {}) {
596
738
  const home = opts.home || process.env.HOME || os.homedir();
597
739
  const status = opts.status || await codexLbStatus({ ...opts, home });
@@ -607,42 +749,77 @@ export async function reconcileCodexLbAuthConflict(opts = {}) {
607
749
  if (!authText.trim()) {
608
750
  return { status: 'skipped', reason: 'auth_empty', auth_path: authPath };
609
751
  }
610
- if (!hasChatgptOAuthTokens(authText)) {
611
- return { status: 'no_oauth_conflict', auth_path: authPath };
612
- }
613
752
  const envText = await readText(status.env_path, '');
614
753
  const apiKey = parseCodexLbEnvKey(envText);
615
754
  if (!apiKey) {
616
755
  return { status: 'skipped', reason: 'missing_env_key', auth_path: authPath };
617
756
  }
618
- // Always back up the OAuth blob — even if reconciliation is opted out — so the data survives.
619
- try {
620
- await ensureDir(path.dirname(backupPath));
621
- await writeTextAtomic(backupPath, authText);
622
- await fsp.chmod(backupPath, 0o600).catch(() => {});
623
- } catch (err) {
624
- return { status: 'failed', reason: 'backup_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
625
- }
626
- if (process.env.SKS_CODEX_LB_NO_AUTH_RECONCILE === '1' && !opts.force) {
757
+ if (hasChatgptOAuthTokens(authText)) {
758
+ try {
759
+ await ensureDir(path.dirname(backupPath));
760
+ await writeTextAtomic(backupPath, authText);
761
+ await fsp.chmod(backupPath, 0o600).catch(() => {});
762
+ } catch (err) {
763
+ return { status: 'failed', reason: 'backup_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
764
+ }
765
+ if (process.env.SKS_CODEX_LB_NO_AUTH_RECONCILE === '1' && !opts.force) {
766
+ return {
767
+ status: 'backup_only',
768
+ reason: 'SKS_CODEX_LB_NO_AUTH_RECONCILE=1',
769
+ auth_path: authPath,
770
+ backup_path: backupPath
771
+ };
772
+ }
773
+ if (process.env.SKS_CODEX_LB_FORCE_APIKEY_AUTH !== '1') {
774
+ return {
775
+ status: 'oauth_preserved',
776
+ reason: 'codex_app_requires_refreshable_oauth',
777
+ auth_path: authPath,
778
+ backup_path: backupPath
779
+ };
780
+ }
781
+ try {
782
+ const replacement = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2)}\n`;
783
+ await writeTextAtomic(authPath, replacement);
784
+ await fsp.chmod(authPath, 0o600).catch(() => {});
785
+ } catch (err) {
786
+ return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
787
+ }
627
788
  return {
628
- status: 'backup_only',
629
- reason: 'SKS_CODEX_LB_NO_AUTH_RECONCILE=1',
789
+ status: 'apikey_forced',
790
+ reason: 'SKS_CODEX_LB_FORCE_APIKEY_AUTH=1',
630
791
  auth_path: authPath,
631
792
  backup_path: backupPath
632
793
  };
633
794
  }
634
- try {
635
- const replacement = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2)}\n`;
636
- await writeTextAtomic(authPath, replacement);
637
- await fsp.chmod(authPath, 0o600).catch(() => {});
638
- } catch (err) {
639
- return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
795
+
796
+ const currentApiKey = parseCodexAuthApiKey(authText);
797
+ if (currentApiKey && currentApiKey === apiKey) {
798
+ const backupText = await readText(backupPath, '');
799
+ if (hasChatgptOAuthTokens(backupText) && process.env.SKS_CODEX_LB_KEEP_APIKEY_AUTH !== '1') {
800
+ try {
801
+ const restored = backupText.endsWith('\n') ? backupText : `${backupText}\n`;
802
+ await writeTextAtomic(authPath, restored);
803
+ await fsp.chmod(authPath, 0o600).catch(() => {});
804
+ return {
805
+ status: 'oauth_restored',
806
+ reason: 'restored_chatgpt_oauth_for_codex_app',
807
+ auth_path: authPath,
808
+ backup_path: backupPath
809
+ };
810
+ } catch (err) {
811
+ return { status: 'failed', reason: 'restore_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
812
+ }
813
+ }
814
+ return {
815
+ status: 'apikey_auth_active',
816
+ reason: hasChatgptOAuthTokens(backupText) ? 'SKS_CODEX_LB_KEEP_APIKEY_AUTH=1' : 'chatgpt_oauth_backup_missing',
817
+ auth_path: authPath,
818
+ backup_path: backupPath
819
+ };
640
820
  }
641
- return {
642
- status: 'reconciled',
643
- auth_path: authPath,
644
- backup_path: backupPath
645
- };
821
+
822
+ return { status: 'no_oauth_conflict', auth_path: authPath };
646
823
  }
647
824
 
648
825
  // Expose the ChatGPT OAuth backup path so the CLI can surface it in status / release output.
@@ -1730,7 +1907,7 @@ export async function selftestCodexLb(tmp) {
1730
1907
  const codexLbReconcileJson = JSON.parse(codexLbReconcileRepair.stdout);
1731
1908
  const codexLbReconcileAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1732
1909
  const codexLbReconcileBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1733
- 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');
1910
+ if (codexLbReconcileJson.auth_reconcile?.status !== 'oauth_preserved' || !codexLbReconcileAuth.includes('oauth-id') || !codexLbReconcileAuth.includes('oauth-refresh') || codexLbReconcileAuth.includes('sk-test') || !codexLbReconcileBackup.includes('oauth-id') || !codexLbReconcileBackup.includes('oauth-refresh')) throw new Error('selftest: codex-lb oauth reconcile should preserve ChatGPT OAuth and back it up');
1734
1911
  // Opt-out path: SKS_CODEX_LB_NO_AUTH_RECONCILE=1 keeps auth.json untouched but still backs up the OAuth blob.
1735
1912
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1736
1913
  await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
@@ -1740,6 +1917,16 @@ export async function selftestCodexLb(tmp) {
1740
1917
  const codexLbReconcileOptOutAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1741
1918
  const codexLbReconcileOptOutBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1742
1919
  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');
1920
+ // Restore path: older SKS versions could leave the codex-lb API key in auth.json. Repair should
1921
+ // restore the ChatGPT OAuth backup while keeping codex-lb selected for provider routing.
1922
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}\n');
1923
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), `${oauthAuthJson}\n`);
1924
+ const codexLbReconcileRestoreRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1925
+ if (codexLbReconcileRestoreRepair.code !== 0) throw new Error(`selftest: codex-lb oauth restore repair exited ${codexLbReconcileRestoreRepair.code}: ${codexLbReconcileRestoreRepair.stderr}`);
1926
+ const codexLbReconcileRestoreJson = JSON.parse(codexLbReconcileRestoreRepair.stdout);
1927
+ const codexLbReconcileRestoreAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1928
+ const codexLbReconcileRestoreConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1929
+ if (codexLbReconcileRestoreJson.auth_reconcile?.status !== 'oauth_restored' || !codexLbReconcileRestoreAuth.includes('oauth-id') || codexLbReconcileRestoreAuth.includes('sk-test') || !hasTopLevelCodexLbSelected(codexLbReconcileRestoreConfig)) throw new Error('selftest: codex-lb oauth restore should replace apikey auth.json with ChatGPT OAuth backup while keeping codex-lb selected');
1743
1930
  // codex-lb auth: release flow — restore ChatGPT OAuth from backup so the user can return to
1744
1931
  // the official ChatGPT account login. Default deselects model_provider; flags control whether
1745
1932
  // the provider stays selected and whether the backup file is removed after restore.
@@ -1932,7 +2119,7 @@ export async function selftestCodexLb(tmp) {
1932
2119
  });
1933
2120
  if (codexLbNotConfigured.code !== 0 || String(codexLbNotConfigured.stdout || '').includes('codex-lb auth:')) throw new Error('selftest: postinstall should stay quiet when codex-lb is not configured');
1934
2121
  const codexLbStatusText = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-lb', 'status'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1935
- if (!String(codexLbStatusText.stdout || '').includes('Repair provider auth: sks codex-lb repair')) throw new Error('selftest: codex-lb status did not advertise repair command');
2122
+ if (!String(codexLbStatusText.stdout || '').includes('Codex App auth:') || !String(codexLbStatusText.stdout || '').includes('sks codex-lb repair')) throw new Error('selftest: codex-lb status did not advertise App auth state and repair command');
1936
2123
  const nonInteractiveLaunchChainCalls = [];
1937
2124
  const nonInteractiveLaunch = await maybePromptCodexLbSetupForLaunch([], {
1938
2125
  home: codexLbHome,
@@ -2023,6 +2210,21 @@ export async function selftestCodexLb(tmp) {
2023
2210
  }
2024
2211
  );
2025
2212
  if (!okChain.ok || okChain.status !== 'chain_ok' || chainCalls.length !== 2 || !String(chainCalls[0].url).endsWith('/backend-api/codex/responses') || chainCalls[1].body.previous_response_id !== 'resp_selftest_1') throw new Error('selftest: codex-lb response chain health check did not verify previous_response_id continuity');
2213
+ const previousGlobalFetch = globalThis.fetch;
2214
+ const cacheCalls = [];
2215
+ const cachePath = path.join(codexLbHome, '.codex', 'chain-cache-selftest.json');
2216
+ try {
2217
+ globalThis.fetch = async (url, init) => {
2218
+ cacheCalls.push({ url, body: JSON.parse(init.body) });
2219
+ return new Response(JSON.stringify({ id: cacheCalls.length === 1 ? 'resp_cache_1' : 'resp_cache_2' }), { status: 200, headers: { 'content-type': 'application/json' } });
2220
+ };
2221
+ const cacheStatus = { base_url: 'https://cache.example.test/backend-api/codex', env_path: path.join(codexLbHome, '.codex', 'sks-codex-lb.env') };
2222
+ const firstCache = await checkCodexLbResponseChain(cacheStatus, { home: codexLbHome, apiKey: 'sk-test', timeoutMs: 1000, cachePath, now: () => 1000 });
2223
+ const secondCache = await checkCodexLbResponseChain(cacheStatus, { home: codexLbHome, apiKey: 'sk-test', timeoutMs: 1000, cachePath, now: () => 2000 });
2224
+ if (!firstCache.ok || firstCache.status !== 'chain_ok' || secondCache.cached !== true || secondCache.status !== 'chain_ok' || cacheCalls.length !== 2) throw new Error('selftest: codex-lb response chain cache did not avoid repeated launch preflight calls');
2225
+ } finally {
2226
+ globalThis.fetch = previousGlobalFetch;
2227
+ }
2026
2228
  const brokenChain = await checkCodexLbResponseChain(
2027
2229
  { base_url: 'https://lb.example.test/backend-api/codex', env_path: path.join(codexLbHome, '.codex', 'sks-codex-lb.env') },
2028
2230
  {
package/src/cli/main.mjs CHANGED
@@ -81,7 +81,7 @@ import { OPENCLAW_SKILL_NAME, installOpenClawSkill } from '../core/openclaw.mjs'
81
81
  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';
82
82
  import { autoReviewProfileName, autoReviewStatus, autoReviewSummary, enableAutoReview, disableAutoReview, enableMadHighProfile, madHighProfileName } from '../core/auto-review.mjs';
83
83
  import { context7Command } from './context7-command.mjs';
84
- 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';
84
+ import { askPostinstallQuestion, checkCodexLbResponseChain, checkContext7, checkRequiredSkills, codexLbChatgptBackupPath, codexLbStatus, configureCodexLb, ensureCodexCliTool, ensureGlobalCodexFastModeDuringInstall, ensureGlobalCodexSkillsDuringInstall, ensureProjectContext7Config, ensureRelatedCliTools, ensureSksCommandDuringInstall, ensureTmuxCliTool, formatCodexLbRepairResultText, formatCodexLbStatusText, globalCodexSkillsRoot, maybePromptCodexLbSetupForLaunch, maybePromptCodexUpdateForLaunch, postinstall, postinstallBootstrapDecision, releaseCodexLbAuthHold, repairCodexLbAuth, selftestCodexLb, shouldAutoApproveInstall, unselectCodexLbProvider } from './install-helpers.mjs';
85
85
  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';
86
86
  import { openClawCommand } from './openclaw-command.mjs';
87
87
  import { recallPulseCommand } from './recallpulse-command.mjs';
@@ -1150,19 +1150,7 @@ async function codexLbCommand(action = 'status', args = []) {
1150
1150
  const backupPath = codexLbChatgptBackupPath();
1151
1151
  const backupPresent = await exists(backupPath);
1152
1152
  if (json) return console.log(JSON.stringify({ ...status, chatgpt_backup_present: backupPresent, chatgpt_backup_path: backupPath }, null, 2));
1153
- console.log('SKS codex-lb\n');
1154
- console.log(`Configured: ${status.ok ? 'yes' : 'no'}`);
1155
- console.log(`Selected: ${status.selected ? 'yes' : 'no'}`);
1156
- console.log(`Provider: ${status.provider_configured ? 'yes' : 'no'}`);
1157
- console.log(`Codex App auth: ${status.provider_requires_openai_auth ? 'yes' : 'missing'}`);
1158
- console.log(`Env file: ${status.env_file ? status.env_path : 'missing'}`);
1159
- if (status.base_url) console.log(`Base URL: ${status.base_url}`);
1160
- console.log(`ChatGPT backup: ${backupPresent ? `yes (${backupPath})` : 'no'}`);
1161
- if (status.ok && !status.selected) console.log('\nRun: sks codex-lb repair to activate codex-lb for Codex App.');
1162
- 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.');
1163
- else if (!status.ok) console.log('\nRun: sks codex-lb setup --host <domain> --api-key <key>');
1164
- else console.log('\nRepair provider auth: sks codex-lb repair');
1165
- if (backupPresent) console.log('Switch back to ChatGPT OAuth login: sks codex-lb release');
1153
+ process.stdout.write(formatCodexLbStatusText(status, { backupPresent, backupPath }));
1166
1154
  return;
1167
1155
  }
1168
1156
  if (sub === 'release') {
@@ -1248,9 +1236,7 @@ async function codexLbCommand(action = 'status', args = []) {
1248
1236
  process.exitCode = 1;
1249
1237
  return;
1250
1238
  }
1251
- console.log('codex-lb provider auth repaired for Codex CLI/App environment.');
1252
- console.log(`Config: ${result.config_path}`);
1253
- console.log(`Key env: ${result.env_path}`);
1239
+ process.stdout.write(formatCodexLbRepairResultText(result));
1254
1240
  return;
1255
1241
  }
1256
1242
  if (sub === 'setup' || sub === 'reconfigure') {
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.8';
8
+ export const PACKAGE_VERSION = '0.9.9';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11