sneakoscope 0.9.8 → 0.9.10

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.10",
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",
@@ -9,6 +9,7 @@ import { formatHarnessConflictReport, llmHarnessCleanupPrompt, scanHarnessConfli
9
9
  import { initProject, installSkills } from '../core/init.mjs';
10
10
  import { context7ConfigToml, DOLLAR_SKILL_NAMES, GETDESIGN_REFERENCE, hasContext7ConfigText, RECOMMENDED_SKILLS } from '../core/routes.mjs';
11
11
  import { codexLaunchCommand, platformTmuxInstallHint, tmuxReadiness } from '../core/tmux-ui.mjs';
12
+ import { reconcileCodexAppUpgradeProcesses } from '../core/codex-app.mjs';
12
13
 
13
14
  const DEFAULT_CODEX_APP_PLUGINS = [
14
15
  ['browser', 'openai-bundled'],
@@ -46,6 +47,11 @@ export async function postinstall({ bootstrap }) {
46
47
  else if (fastModeRepair.status === 'present') console.log('Codex App Fast mode: config already compatible.');
47
48
  else if (fastModeRepair.status === 'skipped') console.log(`Codex App Fast mode: skipped (${fastModeRepair.reason}).`);
48
49
  else if (fastModeRepair.status === 'failed') console.log(`Codex App Fast mode: auto repair failed. Run \`sks setup\`. ${fastModeRepair.error || ''}`.trim());
50
+ const appProcessRepair = await reconcileCodexAppUpgradeProcesses();
51
+ if (appProcessRepair.status === 'repaired') console.log(`Codex App reconnect repair: stopped ${appProcessRepair.killed.length} stale orphan app-server process(es). Restart Codex App to reconnect cleanly.`);
52
+ else if (appProcessRepair.status === 'partial') console.log(`Codex App reconnect repair: stopped ${appProcessRepair.killed.length} stale orphan app-server process(es); ${appProcessRepair.failed.length} could not be stopped. Restart Codex App if reconnecting continues.`);
53
+ else if (appProcessRepair.status === 'skipped' && appProcessRepair.reason !== 'platform') console.log(`Codex App reconnect repair: skipped (${appProcessRepair.reason}).`);
54
+ else if (appProcessRepair.status === 'failed') console.log(`Codex App reconnect repair: skipped (${appProcessRepair.error || appProcessRepair.reason || 'process check failed'}).`);
49
55
  const globalSkills = await ensureGlobalCodexSkillsDuringInstall();
50
56
  if (globalSkills.status === 'installed') {
51
57
  const removed = globalSkills.removed_stale_generated_skills || [];
@@ -88,8 +94,12 @@ async function reportPostinstallCodexLbAuth() {
88
94
  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
95
  else if (codexLbAuth.status && codexLbAuth.status !== 'not_configured') console.log(`codex-lb auth: repair skipped (${codexLbAuth.status}${codexLbAuth.error ? `: ${codexLbAuth.error}` : ''}).`);
90
96
  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.`);
97
+ if (reconcile?.status === 'oauth_preserved') {
98
+ console.log(`codex-lb auth: ChatGPT OAuth preserved for Codex App; codex-lb key stays in env_key (OAuth backup at ${reconcile.backup_path}).`);
99
+ } else if (reconcile?.status === 'oauth_restored') {
100
+ console.log(`codex-lb auth: restored ChatGPT OAuth from ${reconcile.backup_path} while keeping codex-lb selected.`);
101
+ } else if (reconcile?.status === 'apikey_forced') {
102
+ console.log(`codex-lb auth: forced API-key auth.json for CLI-only use (OAuth backup at ${reconcile.backup_path}).`);
93
103
  } else if (reconcile?.status === 'backup_only') {
94
104
  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
105
  } else if (reconcile?.status === 'failed') {
@@ -263,6 +273,9 @@ export async function configureCodexLb(opts = {}) {
263
273
  await fsp.chmod(envPath, 0o600).catch(() => {});
264
274
  const codexEnvironment = await syncCodexLbProviderEnvironment({ env_path: envPath, base_url: baseUrl }, { ...opts, home });
265
275
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home, force: true });
276
+ const codexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
277
+ const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, home, status: codexLb }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
278
+ const finalCodexLb = await codexLbStatus({ ...opts, home, configPath, envPath });
266
279
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
267
280
  return {
268
281
  ok,
@@ -271,9 +284,11 @@ export async function configureCodexLb(opts = {}) {
271
284
  env_path: envPath,
272
285
  base_url: baseUrl,
273
286
  env_key: 'CODEX_LB_API_KEY',
287
+ auth_reconcile: authReconcile,
288
+ codex_lb: finalCodexLb,
274
289
  codex_environment: codexEnvironment,
275
290
  codex_login: codexLogin,
276
- error: codexEnvironment.error || codexLogin.error || null
291
+ error: authReconcile.error || codexEnvironment.error || codexLogin.error || null
277
292
  };
278
293
  }
279
294
 
@@ -284,6 +299,9 @@ export async function codexLbStatus(opts = {}) {
284
299
  const config = await readText(configPath, '');
285
300
  const envExists = await exists(envPath);
286
301
  const envText = envExists ? await readText(envPath, '') : '';
302
+ const authPath = opts.authPath || codexAuthPath(home);
303
+ const authText = await readText(authPath, '');
304
+ const authMode = codexAuthModeSummary(authText);
287
305
  const envKeyConfigured = Boolean(parseCodexLbEnvKey(envText));
288
306
  const providerConfigured = /\[model_providers\.codex-lb\]/.test(config);
289
307
  const selected = hasTopLevelCodexLbSelected(config);
@@ -299,10 +317,52 @@ export async function codexLbStatus(opts = {}) {
299
317
  env_file: envExists,
300
318
  env_key_configured: envKeyConfigured,
301
319
  env_base_url_configured: Boolean(parseCodexLbEnvBaseUrl(envText)),
302
- base_url: baseUrl
320
+ base_url: baseUrl,
321
+ auth_path: authPath,
322
+ auth_mode: authMode.mode,
323
+ auth_usable_for_codex_app: authMode.codex_app_usable,
324
+ auth_summary: authMode.summary
303
325
  };
304
326
  }
305
327
 
328
+ export function formatCodexLbStatusText(status = {}, opts = {}) {
329
+ const backupPresent = Boolean(opts.backupPresent);
330
+ const backupPath = opts.backupPath || '';
331
+ const lines = [
332
+ 'SKS codex-lb',
333
+ '',
334
+ `Configured: ${status.ok ? 'yes' : 'no'}`,
335
+ `Selected: ${status.selected ? 'yes' : 'no'}`,
336
+ `Provider: ${status.provider_configured ? 'yes' : 'no'}`,
337
+ `Provider requires OpenAI auth: ${status.provider_requires_openai_auth ? 'yes' : 'missing'}`,
338
+ `Codex App auth: ${status.auth_usable_for_codex_app ? 'ok' : 'needs sign-in/repair'} (${status.auth_mode || 'unknown'})`
339
+ ];
340
+ if (status.auth_summary) lines.push(`Auth detail: ${status.auth_summary}`);
341
+ lines.push(`Env file: ${status.env_file ? status.env_path : 'missing'}`);
342
+ if (status.base_url) lines.push(`Base URL: ${status.base_url}`);
343
+ lines.push(`ChatGPT backup: ${backupPresent ? `yes (${backupPath})` : 'no'}`);
344
+ 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.');
345
+ 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');
346
+ else if (status.ok && !status.selected) lines.push('', 'Run: sks codex-lb repair to activate codex-lb for Codex App.');
347
+ 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.');
348
+ else if (!status.ok) lines.push('', 'Run: sks codex-lb setup --host <domain> --api-key <key>');
349
+ else lines.push('', 'Repair provider auth: sks codex-lb repair');
350
+ if (backupPresent) lines.push('Switch fully away from codex-lb: sks codex-lb release');
351
+ return `${lines.join('\n')}\n`;
352
+ }
353
+
354
+ export function formatCodexLbRepairResultText(result = {}) {
355
+ const lines = [
356
+ 'codex-lb provider auth repaired for Codex CLI/App environment.',
357
+ `Config: ${result.config_path}`,
358
+ `Key env: ${result.env_path}`
359
+ ];
360
+ if (result.auth_reconcile?.status === 'oauth_restored') lines.push(`Codex App auth: ChatGPT OAuth restored from ${result.auth_reconcile.backup_path}.`);
361
+ 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.');
362
+ 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.');
363
+ return `${lines.join('\n')}\n`;
364
+ }
365
+
306
366
  function codexLbResponsesEndpoint(baseUrl = '') {
307
367
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
308
368
  if (!base) return '';
@@ -313,6 +373,77 @@ function codexLbChainCheckEnabled(env = process.env) {
313
373
  return env.SKS_CODEX_LB_CHAIN_CHECK !== '0' && env.SKS_SKIP_CODEX_LB_CHAIN_CHECK !== '1';
314
374
  }
315
375
 
376
+ function codexLbChainCachePath(home = process.env.HOME || os.homedir()) {
377
+ return path.join(home, '.codex', 'sks-codex-lb-chain-health.json');
378
+ }
379
+
380
+ function codexLbChainCacheTtlMs(status = '', env = process.env) {
381
+ const hardFailure = Boolean(status && !['chain_ok', 'previous_response_not_found'].includes(status));
382
+ const key = hardFailure ? 'SKS_CODEX_LB_CHAIN_CHECK_FAILURE_CACHE_TTL_MS' : 'SKS_CODEX_LB_CHAIN_CHECK_CACHE_TTL_MS';
383
+ const fallback = hardFailure ? 30 * 1000 : 5 * 60 * 1000;
384
+ const raw = env[key] ?? env.SKS_CODEX_LB_CHAIN_CHECK_CACHE_TTL_MS;
385
+ if (raw === undefined || raw === '') return fallback;
386
+ const parsed = Number(raw);
387
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
388
+ }
389
+
390
+ function codexLbChainCacheEnabled(opts = {}, env = process.env) {
391
+ if (opts.force || opts.cache === false) return false;
392
+ if (opts.fetch) return false;
393
+ if (env.SKS_CODEX_LB_CHAIN_CHECK_CACHE === '0') return false;
394
+ return true;
395
+ }
396
+
397
+ async function readCodexLbChainCache({ endpoint, home, opts = {}, env = process.env } = {}) {
398
+ if (!endpoint || !codexLbChainCacheEnabled(opts, env)) return null;
399
+ const cachePath = opts.cachePath || codexLbChainCachePath(home || env.HOME || os.homedir());
400
+ const text = await readText(cachePath, '');
401
+ if (!text.trim()) return null;
402
+ try {
403
+ const parsed = JSON.parse(text);
404
+ if (parsed?.schema !== 'sks.codex-lb-chain-health.v1' || parsed.endpoint !== endpoint || !parsed.result?.status) return null;
405
+ const now = typeof opts.now === 'function' ? opts.now() : Date.now();
406
+ const checkedAt = Number(parsed.checked_at_ms || 0);
407
+ const ttlMs = codexLbChainCacheTtlMs(parsed.result.status, env);
408
+ if (!checkedAt || ttlMs <= 0 || now - checkedAt > ttlMs) return null;
409
+ return {
410
+ ...parsed.result,
411
+ endpoint,
412
+ cached: true,
413
+ cache_path: cachePath,
414
+ cache_age_ms: Math.max(0, now - checkedAt)
415
+ };
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ async function writeCodexLbChainCache(result = {}, { endpoint, home, opts = {}, env = process.env } = {}) {
422
+ if (!endpoint || !result.status || !codexLbChainCacheEnabled(opts, env)) return result;
423
+ const cachePath = opts.cachePath || codexLbChainCachePath(home || env.HOME || os.homedir());
424
+ const now = typeof opts.now === 'function' ? opts.now() : Date.now();
425
+ const cacheResult = {
426
+ ok: Boolean(result.ok),
427
+ status: result.status,
428
+ chain_unhealthy: result.chain_unhealthy === true,
429
+ http_status: result.http_status || null,
430
+ error: result.error || null
431
+ };
432
+ try {
433
+ await ensureDir(path.dirname(cachePath));
434
+ await writeTextAtomic(cachePath, `${JSON.stringify({
435
+ schema: 'sks.codex-lb-chain-health.v1',
436
+ endpoint,
437
+ checked_at_ms: now,
438
+ result: cacheResult
439
+ }, null, 2)}\n`);
440
+ await fsp.chmod(cachePath, 0o600).catch(() => {});
441
+ } catch {
442
+ // Cache writes are a launch optimization only; never block codex-lb startup.
443
+ }
444
+ return result;
445
+ }
446
+
316
447
  function isPreviousResponseNotFound(payload = {}) {
317
448
  const error = payload?.error || payload?.response?.error || payload;
318
449
  const text = typeof error === 'string'
@@ -382,8 +513,11 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
382
513
  if (!codexLbChainCheckEnabled(env) && !opts.force) return { ok: true, status: 'skipped', skipped: true, reason: 'SKS_CODEX_LB_CHAIN_CHECK=0' };
383
514
  const endpoint = codexLbResponsesEndpoint(opts.baseUrl || status.base_url);
384
515
  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()), ''));
516
+ const home = opts.home || env.HOME || os.homedir();
517
+ const apiKey = opts.apiKey || parseCodexLbEnvKey(await readText(opts.envPath || status.env_path || codexLbEnvPath(home), ''));
386
518
  if (!apiKey) return { ok: false, status: 'missing_env_key', chain_unhealthy: true };
519
+ const cached = await readCodexLbChainCache({ endpoint, home, opts, env });
520
+ if (cached) return cached;
387
521
  const fetchImpl = opts.fetch || globalThis.fetch;
388
522
  if (typeof fetchImpl !== 'function') return { ok: true, status: 'skipped', skipped: true, reason: 'fetch unavailable' };
389
523
  const model = opts.model || env.SKS_CODEX_MODEL || 'gpt-5.5';
@@ -400,19 +534,19 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
400
534
  };
401
535
  const first = await fetchCodexLbResponse(fetchImpl, endpoint, apiKey, baseBody, timeoutMs);
402
536
  if (!first.ok || !first.response_id) {
403
- return {
537
+ return writeCodexLbChainCache({
404
538
  ok: false,
405
539
  status: first.ok ? 'missing_response_id' : 'first_request_failed',
406
540
  chain_unhealthy: true,
407
541
  endpoint,
408
542
  http_status: first.status,
409
543
  error: redactSecretText(first.error_payload?.error?.message || first.error_payload?.response?.error?.message || first.text || 'codex-lb first Responses request failed', [apiKey])
410
- };
544
+ }, { endpoint, home, opts, env });
411
545
  }
412
546
  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 };
547
+ 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
548
  const previousMissing = isPreviousResponseNotFound(second.error_payload || second.json || second.text);
415
- return {
549
+ return writeCodexLbChainCache({
416
550
  ok: false,
417
551
  status: previousMissing ? 'previous_response_not_found' : 'second_request_failed',
418
552
  chain_unhealthy: true,
@@ -420,7 +554,7 @@ export async function checkCodexLbResponseChain(status = {}, opts = {}) {
420
554
  response_id: first.response_id,
421
555
  http_status: second.status,
422
556
  error: redactSecretText(second.error_payload?.error?.message || second.error_payload?.response?.error?.message || second.text || 'codex-lb chained Responses request failed', [apiKey])
423
- };
557
+ }, { endpoint, home, opts, env });
424
558
  }
425
559
 
426
560
  function hasTopLevelCodexLbSelected(text = '') {
@@ -473,6 +607,7 @@ export async function repairCodexLbAuth(opts = {}) {
473
607
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
474
608
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
475
609
  const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
610
+ const finalStatus = await codexLbStatus(opts);
476
611
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
477
612
  return {
478
613
  ok,
@@ -484,7 +619,7 @@ export async function repairCodexLbAuth(opts = {}) {
484
619
  legacy_auth_migrated: legacyAuthMigrated,
485
620
  legacy_auth_path: legacyAuthPath,
486
621
  auth_reconcile: authReconcile,
487
- codex_lb: status,
622
+ codex_lb: finalStatus,
488
623
  codex_environment: codexEnvironment,
489
624
  codex_login: codexLogin
490
625
  };
@@ -504,6 +639,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
504
639
  const apiKey = parseCodexLbEnvKey(await readText(status.env_path, ''));
505
640
  const codexLogin = await maybeSyncCodexLbSharedLogin(apiKey, { ...opts, home: opts.home || process.env.HOME || os.homedir(), force: true });
506
641
  const authReconcile = await reconcileCodexLbAuthConflict({ ...opts, status }).catch((err) => ({ status: 'failed', reason: 'exception', error: err.message }));
642
+ const finalStatus = await codexLbStatus(opts);
507
643
  const ok = Boolean(codexEnvironment.ok && codexLogin.ok);
508
644
  return {
509
645
  ok,
@@ -511,7 +647,7 @@ export async function ensureCodexLbAuthDuringInstall(opts = {}) {
511
647
  config_path: status.config_path,
512
648
  env_path: status.env_path,
513
649
  base_url: status.base_url,
514
- codex_lb: status,
650
+ codex_lb: finalStatus,
515
651
  codex_environment: codexEnvironment,
516
652
  codex_login: codexLogin,
517
653
  auth_reconcile: authReconcile,
@@ -565,6 +701,19 @@ function parseCodexAuthApiKey(text = '') {
565
701
  }
566
702
  }
567
703
 
704
+ function codexAuthModeSummary(text = '') {
705
+ const raw = String(text || '').trim();
706
+ if (!raw) return { mode: 'missing', codex_app_usable: false, summary: 'missing auth.json' };
707
+ if (hasChatgptOAuthTokens(raw)) return { mode: 'chatgpt_oauth', codex_app_usable: true, summary: 'ChatGPT OAuth token blob present' };
708
+ const apiKey = parseCodexAuthApiKey(raw);
709
+ 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' };
710
+ try {
711
+ const parsed = JSON.parse(raw);
712
+ if (parsed?.auth_mode === 'browser') return { mode: 'browser_marker', codex_app_usable: false, summary: 'browser auth marker without refresh tokens' };
713
+ } catch {}
714
+ return { mode: 'unknown', codex_app_usable: false, summary: 'unrecognized auth.json shape' };
715
+ }
716
+
568
717
  // Migrate auth.json from legacy {"auth_mode":"apikey","key":"..."} to the codex 0.130.0+
569
718
  // format {"auth_mode":"apikey","OPENAI_API_KEY":"..."}. Safe: preserves key value, only renames field.
570
719
  async function migrateCodexAuthKeyFormat(opts = {}) {
@@ -587,11 +736,10 @@ async function migrateCodexAuthKeyFormat(opts = {}) {
587
736
  }
588
737
  }
589
738
 
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).
739
+ // Codex App needs a refreshable ChatGPT OAuth blob when a provider declares
740
+ // requires_openai_auth=true. For codex-lb, the proxy key belongs in env_key
741
+ // (CODEX_LB_API_KEY), so SKS preserves or restores OAuth by default and only
742
+ // writes apikey auth.json when explicitly requested for CLI-only legacy use.
595
743
  export async function reconcileCodexLbAuthConflict(opts = {}) {
596
744
  const home = opts.home || process.env.HOME || os.homedir();
597
745
  const status = opts.status || await codexLbStatus({ ...opts, home });
@@ -607,42 +755,77 @@ export async function reconcileCodexLbAuthConflict(opts = {}) {
607
755
  if (!authText.trim()) {
608
756
  return { status: 'skipped', reason: 'auth_empty', auth_path: authPath };
609
757
  }
610
- if (!hasChatgptOAuthTokens(authText)) {
611
- return { status: 'no_oauth_conflict', auth_path: authPath };
612
- }
613
758
  const envText = await readText(status.env_path, '');
614
759
  const apiKey = parseCodexLbEnvKey(envText);
615
760
  if (!apiKey) {
616
761
  return { status: 'skipped', reason: 'missing_env_key', auth_path: authPath };
617
762
  }
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) {
763
+ if (hasChatgptOAuthTokens(authText)) {
764
+ try {
765
+ await ensureDir(path.dirname(backupPath));
766
+ await writeTextAtomic(backupPath, authText);
767
+ await fsp.chmod(backupPath, 0o600).catch(() => {});
768
+ } catch (err) {
769
+ return { status: 'failed', reason: 'backup_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
770
+ }
771
+ if (process.env.SKS_CODEX_LB_NO_AUTH_RECONCILE === '1' && !opts.force) {
772
+ return {
773
+ status: 'backup_only',
774
+ reason: 'SKS_CODEX_LB_NO_AUTH_RECONCILE=1',
775
+ auth_path: authPath,
776
+ backup_path: backupPath
777
+ };
778
+ }
779
+ if (process.env.SKS_CODEX_LB_FORCE_APIKEY_AUTH !== '1') {
780
+ return {
781
+ status: 'oauth_preserved',
782
+ reason: 'codex_app_requires_refreshable_oauth',
783
+ auth_path: authPath,
784
+ backup_path: backupPath
785
+ };
786
+ }
787
+ try {
788
+ const replacement = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2)}\n`;
789
+ await writeTextAtomic(authPath, replacement);
790
+ await fsp.chmod(authPath, 0o600).catch(() => {});
791
+ } catch (err) {
792
+ return { status: 'failed', reason: 'write_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
793
+ }
627
794
  return {
628
- status: 'backup_only',
629
- reason: 'SKS_CODEX_LB_NO_AUTH_RECONCILE=1',
795
+ status: 'apikey_forced',
796
+ reason: 'SKS_CODEX_LB_FORCE_APIKEY_AUTH=1',
630
797
  auth_path: authPath,
631
798
  backup_path: backupPath
632
799
  };
633
800
  }
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 };
801
+
802
+ const currentApiKey = parseCodexAuthApiKey(authText);
803
+ if (currentApiKey && currentApiKey === apiKey) {
804
+ const backupText = await readText(backupPath, '');
805
+ if (hasChatgptOAuthTokens(backupText) && process.env.SKS_CODEX_LB_KEEP_APIKEY_AUTH !== '1') {
806
+ try {
807
+ const restored = backupText.endsWith('\n') ? backupText : `${backupText}\n`;
808
+ await writeTextAtomic(authPath, restored);
809
+ await fsp.chmod(authPath, 0o600).catch(() => {});
810
+ return {
811
+ status: 'oauth_restored',
812
+ reason: 'restored_chatgpt_oauth_for_codex_app',
813
+ auth_path: authPath,
814
+ backup_path: backupPath
815
+ };
816
+ } catch (err) {
817
+ return { status: 'failed', reason: 'restore_failed', auth_path: authPath, backup_path: backupPath, error: err.message };
818
+ }
819
+ }
820
+ return {
821
+ status: 'apikey_auth_active',
822
+ reason: hasChatgptOAuthTokens(backupText) ? 'SKS_CODEX_LB_KEEP_APIKEY_AUTH=1' : 'chatgpt_oauth_backup_missing',
823
+ auth_path: authPath,
824
+ backup_path: backupPath
825
+ };
640
826
  }
641
- return {
642
- status: 'reconciled',
643
- auth_path: authPath,
644
- backup_path: backupPath
645
- };
827
+
828
+ return { status: 'no_oauth_conflict', auth_path: authPath };
646
829
  }
647
830
 
648
831
  // Expose the ChatGPT OAuth backup path so the CLI can surface it in status / release output.
@@ -1589,6 +1772,7 @@ function codexLbPostinstallEnv(baseEnv, overrides = {}) {
1589
1772
  SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS: '1',
1590
1773
  SKS_SKIP_POSTINSTALL_CODEX_LB_AUTH: '0',
1591
1774
  SKS_SKIP_CODEX_LB_LAUNCH_ENV: '1',
1775
+ SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1',
1592
1776
  ...overrides
1593
1777
  };
1594
1778
  }
@@ -1661,7 +1845,7 @@ export async function selftestCodexLb(tmp) {
1661
1845
  const codexLbPostinstallAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1662
1846
  const codexLbLoginCallsAfterPostinstall = await codexLbLoginCallCount(codexLbHome);
1663
1847
  if (!String(codexLbPostinstall.stdout || '').includes('codex-lb auth: preserved') || !codexLbPostinstallAuth.includes('"auth_mode":"browser"') || codexLbPostinstallAuth.includes('sk-test') || codexLbLoginCallsAfterPostinstall !== codexLbLoginCallsBeforePostinstall) throw new Error('selftest: postinstall auth');
1664
- const postinstallEnvKeys = ['HOME', 'PATH', 'INIT_CWD', 'SKS_GLOBAL_ROOT', 'SKS_POSTINSTALL_BOOTSTRAP', 'SKS_POSTINSTALL_NO_BOOTSTRAP', 'SKS_SKIP_POSTINSTALL_SHIM', 'SKS_SKIP_POSTINSTALL_CONTEXT7', 'SKS_SKIP_POSTINSTALL_GETDESIGN', 'SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS', 'SKS_SKIP_POSTINSTALL_CODEX_LB_AUTH', 'SKS_SKIP_CODEX_LB_LAUNCH_ENV', 'SKS_CODEX_LB_SYNC_CODEX_LOGIN'];
1848
+ const postinstallEnvKeys = ['HOME', 'PATH', 'INIT_CWD', 'SKS_GLOBAL_ROOT', 'SKS_POSTINSTALL_BOOTSTRAP', 'SKS_POSTINSTALL_NO_BOOTSTRAP', 'SKS_SKIP_POSTINSTALL_SHIM', 'SKS_SKIP_POSTINSTALL_CONTEXT7', 'SKS_SKIP_POSTINSTALL_GETDESIGN', 'SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS', 'SKS_SKIP_POSTINSTALL_CODEX_LB_AUTH', 'SKS_SKIP_CODEX_LB_LAUNCH_ENV', 'SKS_SKIP_CODEX_APP_UPGRADE_REPAIR', 'SKS_CODEX_LB_SYNC_CODEX_LOGIN'];
1665
1849
  const postinstallEnvBefore = Object.fromEntries(postinstallEnvKeys.map((key) => [key, process.env[key]]));
1666
1850
  const codexLbLoginCallsBeforeBootstrap = await codexLbLoginCallCount(codexLbHome);
1667
1851
  try {
@@ -1677,7 +1861,8 @@ export async function selftestCodexLb(tmp) {
1677
1861
  SKS_SKIP_POSTINSTALL_GETDESIGN: '1',
1678
1862
  SKS_SKIP_POSTINSTALL_GLOBAL_SKILLS: '1',
1679
1863
  SKS_SKIP_POSTINSTALL_CODEX_LB_AUTH: '0',
1680
- SKS_SKIP_CODEX_LB_LAUNCH_ENV: '1'
1864
+ SKS_SKIP_CODEX_LB_LAUNCH_ENV: '1',
1865
+ SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1'
1681
1866
  });
1682
1867
  await postinstall({
1683
1868
  bootstrap: async () => {
@@ -1730,7 +1915,7 @@ export async function selftestCodexLb(tmp) {
1730
1915
  const codexLbReconcileJson = JSON.parse(codexLbReconcileRepair.stdout);
1731
1916
  const codexLbReconcileAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1732
1917
  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');
1918
+ 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
1919
  // Opt-out path: SKS_CODEX_LB_NO_AUTH_RECONCILE=1 keeps auth.json untouched but still backs up the OAuth blob.
1735
1920
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1736
1921
  await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
@@ -1740,6 +1925,16 @@ export async function selftestCodexLb(tmp) {
1740
1925
  const codexLbReconcileOptOutAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1741
1926
  const codexLbReconcileOptOutBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1742
1927
  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');
1928
+ // Restore path: older SKS versions could leave the codex-lb API key in auth.json. Repair should
1929
+ // restore the ChatGPT OAuth backup while keeping codex-lb selected for provider routing.
1930
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}\n');
1931
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), `${oauthAuthJson}\n`);
1932
+ const codexLbReconcileRestoreRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1933
+ if (codexLbReconcileRestoreRepair.code !== 0) throw new Error(`selftest: codex-lb oauth restore repair exited ${codexLbReconcileRestoreRepair.code}: ${codexLbReconcileRestoreRepair.stderr}`);
1934
+ const codexLbReconcileRestoreJson = JSON.parse(codexLbReconcileRestoreRepair.stdout);
1935
+ const codexLbReconcileRestoreAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1936
+ const codexLbReconcileRestoreConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1937
+ 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
1938
  // codex-lb auth: release flow — restore ChatGPT OAuth from backup so the user can return to
1744
1939
  // the official ChatGPT account login. Default deselects model_provider; flags control whether
1745
1940
  // the provider stays selected and whether the backup file is removed after restore.
@@ -1932,7 +2127,7 @@ export async function selftestCodexLb(tmp) {
1932
2127
  });
1933
2128
  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
2129
  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');
2130
+ 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
2131
  const nonInteractiveLaunchChainCalls = [];
1937
2132
  const nonInteractiveLaunch = await maybePromptCodexLbSetupForLaunch([], {
1938
2133
  home: codexLbHome,
@@ -2023,6 +2218,21 @@ export async function selftestCodexLb(tmp) {
2023
2218
  }
2024
2219
  );
2025
2220
  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');
2221
+ const previousGlobalFetch = globalThis.fetch;
2222
+ const cacheCalls = [];
2223
+ const cachePath = path.join(codexLbHome, '.codex', 'chain-cache-selftest.json');
2224
+ try {
2225
+ globalThis.fetch = async (url, init) => {
2226
+ cacheCalls.push({ url, body: JSON.parse(init.body) });
2227
+ return new Response(JSON.stringify({ id: cacheCalls.length === 1 ? 'resp_cache_1' : 'resp_cache_2' }), { status: 200, headers: { 'content-type': 'application/json' } });
2228
+ };
2229
+ const cacheStatus = { base_url: 'https://cache.example.test/backend-api/codex', env_path: path.join(codexLbHome, '.codex', 'sks-codex-lb.env') };
2230
+ const firstCache = await checkCodexLbResponseChain(cacheStatus, { home: codexLbHome, apiKey: 'sk-test', timeoutMs: 1000, cachePath, now: () => 1000 });
2231
+ const secondCache = await checkCodexLbResponseChain(cacheStatus, { home: codexLbHome, apiKey: 'sk-test', timeoutMs: 1000, cachePath, now: () => 2000 });
2232
+ 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');
2233
+ } finally {
2234
+ globalThis.fetch = previousGlobalFetch;
2235
+ }
2026
2236
  const brokenChain = await checkCodexLbResponseChain(
2027
2237
  { base_url: 'https://lb.example.test/backend-api/codex', env_path: path.join(codexLbHome, '.codex', 'sks-codex-lb.env') },
2028
2238
  {
package/src/cli/main.mjs CHANGED
@@ -73,7 +73,7 @@ import { MISTAKE_RECALL_ARTIFACT, contractConsumesMistakeRecall } from '../core/
73
73
  import { buildPromptContext } from '../core/prompt-context-builder.mjs';
74
74
  import { renderTeamDashboardState, writeTeamDashboardState } from '../core/team-dashboard-renderer.mjs';
75
75
  import { GOAL_WORKFLOW_ARTIFACT } from '../core/goal-workflow.mjs';
76
- import { CODEX_APP_DOCS_URL, codexAccessTokenStatus, codexAppIntegrationStatus, formatCodexAppStatus } from '../core/codex-app.mjs';
76
+ import { CODEX_APP_DOCS_URL, codexAccessTokenStatus, codexAppIntegrationStatus, findCodexAppUpgradeRepairTargets, formatCodexAppStatus, parseProcessRows } from '../core/codex-app.mjs';
77
77
  import { buildAllFeaturesSelftest, buildFeatureRegistry, validateFeatureRegistry } from '../core/feature-registry.mjs';
78
78
  import { codexAppRemoteControlCommand } from './codex-app-command.mjs';
79
79
  import { allFeaturesCommand, featuresCommand, hooksCommand, hooksExplainReport } from './feature-commands.mjs';
@@ -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') {
@@ -2303,11 +2289,11 @@ async function selftest() {
2303
2289
  await ensureDir(path.join(conflictTmp, '.omx'));
2304
2290
  const conflictScan = await scanHarnessConflicts(conflictTmp, { home: path.join(conflictTmp, 'home') });
2305
2291
  if (!conflictScan.hard_block || !formatHarnessConflictReport(conflictScan).includes('GPT-5.5')) throw new Error('selftest: OMX conflict did not block with cleanup prompt');
2306
- const postinstallConflict = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: conflictTmp, env: { INIT_CWD: conflictTmp, HOME: path.join(conflictTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
2292
+ const postinstallConflict = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: conflictTmp, env: { INIT_CWD: conflictTmp, HOME: path.join(conflictTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
2307
2293
  if (postinstallConflict.code !== 0) throw new Error('selftest: postinstall conflict notice should not make npm install fail');
2308
2294
  const postinstallConflictOutput = String(`${postinstallConflict.stdout}\n${postinstallConflict.stderr}`);
2309
2295
  if (!postinstallConflictOutput.includes('SKS setup is blocked') || postinstallConflictOutput.includes('Cleanup prompt:')) throw new Error('selftest: postinstall conflict notice did not stay informational');
2310
- const postinstallConflictPrompt = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: conflictTmp, input: 'y\n', env: { INIT_CWD: conflictTmp, HOME: path.join(conflictTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_POSTINSTALL_PROMPT: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
2296
+ const postinstallConflictPrompt = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: conflictTmp, input: 'y\n', env: { INIT_CWD: conflictTmp, HOME: path.join(conflictTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1', SKS_POSTINSTALL_PROMPT: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
2311
2297
  if (postinstallConflictPrompt.code !== 0 || !String(postinstallConflictPrompt.stdout || '').includes('Goal: completely remove the conflicting Codex harnesses')) throw new Error('selftest: conflict prompt');
2312
2298
  const postinstallSetupTmp = tmpdir();
2313
2299
  await writeJsonAtomic(path.join(postinstallSetupTmp, 'package.json'), { name: 'postinstall-setup-smoke', version: '0.0.0' });
@@ -2317,7 +2303,7 @@ async function selftest() {
2317
2303
  await ensureDir(path.join(postinstallSetupHome, '.agents', 'skills', name));
2318
2304
  await writeTextAtomic(path.join(postinstallSetupHome, '.agents', 'skills', name, 'SKILL.md'), stalePluginSkillContent(name));
2319
2305
  }
2320
- const postinstallSetup = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: postinstallSetupTmp, env: { INIT_CWD: postinstallSetupTmp, HOME: path.join(postinstallSetupTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CLI_TOOLS: '1' }, timeoutMs: 30000, maxOutputBytes: 256 * 1024 });
2306
+ const postinstallSetup = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: postinstallSetupTmp, env: { INIT_CWD: postinstallSetupTmp, HOME: path.join(postinstallSetupTmp, 'home'), SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1', SKS_SKIP_CLI_TOOLS: '1' }, timeoutMs: 30000, maxOutputBytes: 256 * 1024 });
2321
2307
  if (postinstallSetup.code !== 0) throw new Error(`selftest: postinstall setup exited ${postinstallSetup.code}: ${postinstallSetup.stderr}`);
2322
2308
  if (await exists(path.join(postinstallSetupTmp, '.agents', 'skills', 'agent-team', 'SKILL.md'))) throw new Error('selftest: postinstall installed deprecated agent-team fallback skill');
2323
2309
  if (!String(postinstallSetup.stdout || '').includes('SKS bootstrap: auto-running sks setup --bootstrap --install-scope global --force') || !String(postinstallSetup.stdout || '').includes('SKS Ready')) throw new Error('selftest: postinstall bootstrap');
@@ -2355,7 +2341,7 @@ async function selftest() {
2355
2341
  const postinstallNoMarkerCwd = path.join(postinstallNoMarkerTmp, 'cwd');
2356
2342
  const postinstallNoMarkerGlobalRoot = path.join(postinstallNoMarkerTmp, 'global-root');
2357
2343
  await ensureDir(postinstallNoMarkerCwd);
2358
- const postinstallNoMarker = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: postinstallNoMarkerCwd, env: { INIT_CWD: postinstallNoMarkerCwd, HOME: postinstallNoMarkerHome, SKS_GLOBAL_ROOT: postinstallNoMarkerGlobalRoot, SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CLI_TOOLS: '1' }, timeoutMs: 30000, maxOutputBytes: 256 * 1024 });
2344
+ const postinstallNoMarker = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'postinstall'], { cwd: postinstallNoMarkerCwd, env: { INIT_CWD: postinstallNoMarkerCwd, HOME: postinstallNoMarkerHome, SKS_GLOBAL_ROOT: postinstallNoMarkerGlobalRoot, SKS_SKIP_POSTINSTALL_SHIM: '1', SKS_SKIP_POSTINSTALL_CONTEXT7: '1', SKS_SKIP_POSTINSTALL_GETDESIGN: '1', SKS_SKIP_CODEX_APP_UPGRADE_REPAIR: '1', SKS_SKIP_CLI_TOOLS: '1' }, timeoutMs: 30000, maxOutputBytes: 256 * 1024 });
2359
2345
  if (postinstallNoMarker.code !== 0) throw new Error(`selftest: no-marker postinstall bootstrap exited ${postinstallNoMarker.code}: ${postinstallNoMarker.stderr}`);
2360
2346
  if (!String(postinstallNoMarker.stdout || '').includes('no project marker found; auto-running global SKS runtime bootstrap')) throw new Error('selftest: no-marker bootstrap');
2361
2347
  if (!(await exists(path.join(postinstallNoMarkerGlobalRoot, '.sneakoscope', 'manifest.json')))) throw new Error('selftest: no-marker postinstall did not bootstrap global runtime root');
@@ -3196,6 +3182,15 @@ async function selftest() {
3196
3182
  const codexAppFixtureOpts = { codex: { bin: fakeCodex, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } };
3197
3183
  const codexAppFeatureStatus = await codexAppIntegrationStatus(codexAppFixtureOpts);
3198
3184
  if (!codexAppFeatureStatus.ok || !codexAppFeatureStatus.features?.required_flags_ok || !codexAppFeatureStatus.features?.codex_git_commit || !codexAppFeatureStatus.features?.remote_control || !codexAppFeatureStatus.features?.git_actions?.ok || !codexAppFeatureStatus.features?.fast_mode_config?.ok) throw new Error('selftest: codex-app check did not accept required app feature flags, git actions, remote_control, and unlocked Fast UI config');
3185
+ const codexAppProcessRows = parseProcessRows([
3186
+ '101 1 /Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled',
3187
+ '200 1 /Applications/Codex.app/Contents/MacOS/Codex',
3188
+ '201 200 /Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled',
3189
+ '202 1 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio://',
3190
+ '203 1 /Users/me/.nvm/versions/node/bin/codex --model gpt-5.5'
3191
+ ].join('\n'));
3192
+ const codexAppRepairTargets = findCodexAppUpgradeRepairTargets(codexAppProcessRows);
3193
+ if (codexAppRepairTargets.length !== 1 || codexAppRepairTargets[0].pid !== 101) throw new Error('selftest: Codex App upgrade repair target selection is not limited to orphan desktop app-server processes');
3199
3194
  const codexAppOldCliStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodex, version: 'codex-cli 0.129.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3200
3195
  if (codexAppOldCliStatus.ok || codexAppOldCliStatus.features?.git_actions?.ok || !codexAppOldCliStatus.guidance.some((line) => line.includes('git commit/push actions are blocked'))) throw new Error('selftest: codex-app check did not block commit/push actions on old Codex CLI remote-control');
3201
3196
  const missingDefaultPluginTmp = tmpdir();
@@ -238,6 +238,65 @@ export function codexSupportsRemoteControl(versionText) {
238
238
  return Boolean(current && compareVersions(current, CODEX_REMOTE_CONTROL_MIN_VERSION) >= 0);
239
239
  }
240
240
 
241
+ export function parseProcessRows(text = '') {
242
+ return String(text || '')
243
+ .split(/\r?\n/)
244
+ .map((line) => line.trim())
245
+ .filter(Boolean)
246
+ .map((line) => {
247
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
248
+ if (!match) return null;
249
+ return {
250
+ pid: Number.parseInt(match[1], 10),
251
+ ppid: Number.parseInt(match[2], 10),
252
+ command: match[3]
253
+ };
254
+ })
255
+ .filter((row) => Number.isFinite(row?.pid) && Number.isFinite(row?.ppid) && row.command);
256
+ }
257
+
258
+ export function findCodexAppUpgradeRepairTargets(rows = []) {
259
+ return rows.filter((row) => (
260
+ row?.ppid === 1
261
+ && /\/Codex\.app\/Contents\/Resources\/codex\s+app-server\s+--analytics-default-enabled(?:\s|$)/.test(String(row.command || ''))
262
+ ));
263
+ }
264
+
265
+ export async function reconcileCodexAppUpgradeProcesses(opts = {}) {
266
+ const platform = opts.platform || process.platform;
267
+ const env = opts.env || process.env;
268
+ if (platform !== 'darwin') return { status: 'skipped', reason: 'platform', killed: [] };
269
+ if (env.SKS_SKIP_CODEX_APP_UPGRADE_REPAIR === '1') return { status: 'skipped', reason: 'SKS_SKIP_CODEX_APP_UPGRADE_REPAIR=1', killed: [] };
270
+ const run = opts.runProcess || runProcess;
271
+ const ps = await run('ps', ['-axo', 'pid=', '-o', 'ppid=', '-o', 'command='], {
272
+ timeoutMs: opts.timeoutMs || 5000,
273
+ maxOutputBytes: opts.maxOutputBytes || 256 * 1024
274
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
275
+ if (ps.code !== 0) return { status: 'failed', reason: 'ps_failed', error: ps.stderr || ps.stdout || 'ps exited non-zero', killed: [] };
276
+ const rows = parseProcessRows(ps.stdout);
277
+ const targets = findCodexAppUpgradeRepairTargets(rows);
278
+ const killed = [];
279
+ const failed = [];
280
+ for (const target of targets) {
281
+ if (opts.dryRun) {
282
+ killed.push({ pid: target.pid, command: target.command, dry_run: true });
283
+ continue;
284
+ }
285
+ const kill = await run('kill', ['-TERM', String(target.pid)], {
286
+ timeoutMs: opts.timeoutMs || 5000,
287
+ maxOutputBytes: 8 * 1024
288
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
289
+ if (kill.code === 0) killed.push({ pid: target.pid, command: target.command });
290
+ else failed.push({ pid: target.pid, command: target.command, error: kill.stderr || kill.stdout || 'kill exited non-zero' });
291
+ }
292
+ return {
293
+ status: failed.length ? 'partial' : killed.length ? 'repaired' : 'clean',
294
+ killed,
295
+ failed,
296
+ checked: rows.length
297
+ };
298
+ }
299
+
241
300
  export function formatCodexRemoteControlStatus(status) {
242
301
  const lines = [
243
302
  'Codex remote-control',
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.10';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11