sneakoscope 0.9.1 → 0.9.4

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
@@ -288,7 +315,9 @@ For headless remotely controllable Codex App/server sessions on Codex CLI 0.130.
288
315
  sks codex-app remote-control -- --help
289
316
  ```
290
317
 
291
- `sks codex-app check` reports whether the installed Codex CLI is new enough, whether the required app flags are visible, whether Fast/speed-selector config is unlocked, and whether installed OpenAI default plugins such as Browser, Chrome, Computer Use, Documents, Presentations, Spreadsheets, and LaTeX are enabled. When codex-lb is configured, SKS keeps it selected as the top-level Codex App provider while still preserving required app flags and plugin settings. Codex CLI 0.130.0+ app-server/remote-control threads can pick up config changes live; older CLI/TUI sessions should still be restarted after `.codex/config.toml` or MCP/plugin changes.
318
+ `sks codex-app check` reports whether the installed Codex CLI is new enough, whether the required app flags are visible, whether Fast/speed-selector config is unlocked, whether Codex App Git Actions can use Commit, Push, Commit and Push, and PR flows, and whether installed OpenAI default plugins such as Browser, Chrome, Computer Use, Documents, Presentations, Spreadsheets, and LaTeX are enabled. When codex-lb is configured, SKS keeps it selected as the top-level Codex App provider while still preserving required app flags and plugin settings. Codex CLI 0.130.0+ app-server/remote-control threads can pick up config changes live; older CLI/TUI sessions should still be restarted after `.codex/config.toml` or MCP/plugin changes.
319
+
320
+ Image-review routes are intentionally strict. `$Image-UX-Review`, `$UX-Review`, `$Visual-Review`, and `$UI-UX-Review` require real Codex App `$imagegen`/`gpt-image-2` generated annotated review images before `image-ux-review-gate.json` can pass; disabled or missing `image_generation` remains a blocker that `sks codex-app check` and selftest cover.
292
321
 
293
322
  Then open Codex App and use prompt commands directly in the chat. Examples:
294
323
 
@@ -297,6 +326,7 @@ $Team implement the checkout fix and verify it
297
326
  $DFix change this label and spacing only
298
327
  $QA-LOOP dogfood localhost:3000 and fix safe issues
299
328
  $PPT create an investor deck as HTML/PDF
329
+ $UX-Review this screenshot with gpt-image-2 callouts, then fix the issues
300
330
  $Goal persist this migration workflow with native /goal continuation
301
331
  $Research investigate this mechanism with source-backed scout lenses
302
332
  $Wiki refresh and validate the context pack
@@ -420,6 +450,15 @@ codex mcp list
420
450
 
421
451
  Codex App workflows need the app installed. UI/browser evidence requires first-party Codex Computer Use, and generated raster/image-review evidence requires real `$imagegen`/`gpt-image-2` output. After setup/upgrade, start a fresh thread so Codex reloads plugin tools.
422
452
 
453
+ ### Codex App commit/push is blocked
454
+
455
+ ```sh
456
+ sks doctor --fix
457
+ sks codex-app check
458
+ ```
459
+
460
+ `sks codex-app check` now prints `Git Actions`. It should be `ok` for Codex App Commit, Push, Commit and Push, and PR buttons to bypass SKS route gates. If it is blocked, repair config with `sks doctor --fix`; if the blocker mentions remote-control, update Codex CLI to `0.130.0` or newer and restart older app-server/TUI sessions.
461
+
423
462
  ### Codex App UI looks stale after codex-lb changes
424
463
 
425
464
  If Codex App UI panels or auth-dependent controls still look wrong after codex-lb setup, repair, or upgrade, restart the app first. If the UI still does not recover, sign out of Codex App, sign back in, then run `sks codex-app check` or `sks codex-lb repair` as needed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.9.1",
4
+ "version": "0.9.4",
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
+ }
209
+ }
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
+ }
180
222
  }
181
- await writeTextAtomic(snapshot.config_path, next);
182
- return { status: 'restored', config_path: snapshot.config_path };
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);
@@ -1240,7 +1538,10 @@ export async function selftestCodexLb(tmp) {
1240
1538
  const codexLbFakeBin = path.join(tmp, 'codex-lb-fake-bin');
1241
1539
  await ensureDir(codexLbFakeBin);
1242
1540
  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");
1541
+ // NOTE: printf format uses literal double-quotes inside single-quoted shell strings so the
1542
+ // fake login writes proper JSON in both bash and dash (where `\"` is a non-standard printf
1543
+ // escape that dash emits literally and bash collapses to `"`).
1544
+ 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
1545
  await fsp.chmod(codexLbFakeCodex, 0o755);
1245
1546
  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
1547
  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 +1654,88 @@ export async function selftestCodexLb(tmp) {
1353
1654
  const codexLbDoctorConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1354
1655
  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
1656
  if (!codexLbDoctorConfig.includes('requires_openai_auth = true')) throw new Error('selftest: doctor codex-lb did not restore upstream-required requires_openai_auth');
1657
+ // codex-lb auth: ChatGPT OAuth ↔ codex-lb env_key conflict reconciliation.
1658
+ const oauthAuthJson = JSON.stringify({
1659
+ auth_mode: 'chatgpt',
1660
+ tokens: { id_token: 'oauth-id', access_token: 'oauth-access', refresh_token: 'oauth-refresh' },
1661
+ last_refresh: '2026-01-01T00:00:00Z'
1662
+ });
1663
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1664
+ 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");
1665
+ 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');
1666
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1667
+ const codexLbReconcileRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
1668
+ if (codexLbReconcileRepair.code !== 0) throw new Error(`selftest: codex-lb oauth reconcile repair exited ${codexLbReconcileRepair.code}: ${codexLbReconcileRepair.stderr}`);
1669
+ const codexLbReconcileJson = JSON.parse(codexLbReconcileRepair.stdout);
1670
+ const codexLbReconcileAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1671
+ const codexLbReconcileBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1672
+ 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');
1673
+ // Opt-out path: SKS_CODEX_LB_NO_AUTH_RECONCILE=1 keeps auth.json untouched but still backs up the OAuth blob.
1674
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), `${oauthAuthJson}\n`);
1675
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1676
+ 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 });
1677
+ if (codexLbReconcileOptOutRepair.code !== 0) throw new Error(`selftest: codex-lb oauth reconcile opt-out repair exited ${codexLbReconcileOptOutRepair.code}: ${codexLbReconcileOptOutRepair.stderr}`);
1678
+ const codexLbReconcileOptOutJson = JSON.parse(codexLbReconcileOptOutRepair.stdout);
1679
+ const codexLbReconcileOptOutAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1680
+ const codexLbReconcileOptOutBackup = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1681
+ 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');
1682
+ // codex-lb auth: release flow — restore ChatGPT OAuth from backup so the user can return to
1683
+ // the official ChatGPT account login. Default deselects model_provider; flags control whether
1684
+ // the provider stays selected and whether the backup file is removed after restore.
1685
+ 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';
1686
+ const codexLbReleaseEnv = "export CODEX_LB_BASE_URL='https://lb.example.test/backend-api/codex'\nexport CODEX_LB_API_KEY='sk-test'\n";
1687
+ const codexLbReleaseApikeyAuth = '{"auth_mode":"apikey","key":"sk-test"}\n';
1688
+ const codexLbReleaseOauthBackup = `${oauthAuthJson}\n`;
1689
+ // Happy path: deselect model_provider and preserve backup file.
1690
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1691
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1692
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'), codexLbReleaseEnv);
1693
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1694
+ 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 });
1695
+ if (codexLbReleaseRun.code !== 0) throw new Error(`selftest: codex-lb release exited ${codexLbReleaseRun.code}: ${codexLbReleaseRun.stderr}`);
1696
+ const codexLbReleaseJson = JSON.parse(codexLbReleaseRun.stdout);
1697
+ const codexLbReleaseAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1698
+ const codexLbReleaseBackupAfter = await safeReadText(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1699
+ const codexLbReleaseConfigAfter = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1700
+ 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');
1701
+ // --keep-provider: restore auth.json but leave model_provider = "codex-lb" alone.
1702
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1703
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1704
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1705
+ 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 });
1706
+ if (codexLbReleaseKeepRun.code !== 0) throw new Error(`selftest: codex-lb release --keep-provider exited ${codexLbReleaseKeepRun.code}: ${codexLbReleaseKeepRun.stderr}`);
1707
+ const codexLbReleaseKeepJson = JSON.parse(codexLbReleaseKeepRun.stdout);
1708
+ const codexLbReleaseKeepConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1709
+ 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');
1710
+ // --delete-backup: restore auth.json and remove the backup file.
1711
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1712
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), codexLbReleaseOauthBackup);
1713
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1714
+ 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 });
1715
+ if (codexLbReleaseDeleteRun.code !== 0) throw new Error(`selftest: codex-lb release --delete-backup exited ${codexLbReleaseDeleteRun.code}: ${codexLbReleaseDeleteRun.stderr}`);
1716
+ const codexLbReleaseDeleteJson = JSON.parse(codexLbReleaseDeleteRun.stdout);
1717
+ const codexLbReleaseDeleteBackupExists = await exists(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'));
1718
+ 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');
1719
+ // No backup: release should refuse and exit 1.
1720
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1721
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1722
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1723
+ 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 });
1724
+ const codexLbReleaseMissingJson = JSON.parse(codexLbReleaseMissingRun.stdout || '{}');
1725
+ const codexLbReleaseMissingAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1726
+ 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');
1727
+ // unselect: flip model_provider off without touching auth.json or env file.
1728
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), codexLbReleaseApikeyAuth);
1729
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), codexLbReleaseConfig);
1730
+ 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 });
1731
+ if (codexLbUnselectRun.code !== 0) throw new Error(`selftest: codex-lb unselect exited ${codexLbUnselectRun.code}: ${codexLbUnselectRun.stderr}`);
1732
+ const codexLbUnselectJson = JSON.parse(codexLbUnselectRun.stdout);
1733
+ const codexLbUnselectConfig = await safeReadText(path.join(codexLbHome, '.codex', 'config.toml'));
1734
+ const codexLbUnselectAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
1735
+ 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');
1736
+ // Restore the doctor-test auth.json shape so downstream selftest assertions still hold.
1737
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"browser"}\n');
1738
+ await fsp.rm(path.join(codexLbHome, '.codex', 'auth.chatgpt-backup.json'), { force: true });
1356
1739
  const codexLbContext7Bin = path.join(tmp, 'codex-lb-context7-bin');
1357
1740
  await ensureDir(codexLbContext7Bin);
1358
1741
  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');
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
 
@@ -1648,7 +1711,7 @@ async function setup(args) {
1648
1711
  else console.log('Git: .gitignore ignores SKS generated files');
1649
1712
  console.log(`Codex App: .codex/config.toml, .codex/hooks.json, .agents/skills, .codex/agents, .codex/SNEAKOSCOPE.md`);
1650
1713
  console.log(`Global $: ${globalSkills.status === 'installed' ? 'ok' : globalSkills.status} ${globalSkills.root || ''}`.trimEnd());
1651
- console.log(`App tools: ${appRuntime.ok ? 'ok' : 'needs setup'} Codex App=${appRuntime.app.installed ? 'ok' : 'missing'} Browser=${appRuntime.features?.browser_tool_ready ? 'ok' : 'missing'} Computer Use=${appRuntime.mcp.has_computer_use ? 'ok' : 'missing'} Image Gen=${appRuntime.features?.image_generation ? 'ok' : 'missing'}`);
1714
+ console.log(`App tools: ${appRuntime.ok ? 'ok' : 'needs setup'} Codex App=${appRuntime.app.installed ? 'ok' : 'missing'} Browser=${appRuntime.features?.browser_tool_ready ? 'ok' : 'missing'} Computer Use=${appRuntime.mcp.has_computer_use ? 'ok' : 'missing'} Image Gen=${appRuntime.features?.image_generation ? 'ok' : 'missing'} Git Actions=${appRuntime.features?.git_actions?.ok ? 'ok' : 'missing'}`);
1652
1715
  console.log(`Prompt: intent-first routing, $Answer fact-check route, $DFix ultralight Direct Fix route, $PPT HTML/PDF presentation route, Context7 gate`);
1653
1716
  console.log(`Skills: .agents/skills`);
1654
1717
  console.log(`Next: sks context7 check; sks selftest --mock; sks commands; sks dollar-commands`);
@@ -3086,7 +3149,9 @@ async function selftest() {
3086
3149
  await fsp.chmod(fakeCodex, 0o755);
3087
3150
  const codexAppFixtureOpts = { codex: { bin: fakeCodex, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } };
3088
3151
  const codexAppFeatureStatus = await codexAppIntegrationStatus(codexAppFixtureOpts);
3089
- if (!codexAppFeatureStatus.ok || !codexAppFeatureStatus.features?.required_flags_ok || !codexAppFeatureStatus.features?.codex_git_commit || !codexAppFeatureStatus.features?.remote_control || !codexAppFeatureStatus.features?.fast_mode_config?.ok) throw new Error('selftest: codex-app check did not accept required app feature flags, remote_control, and unlocked Fast UI config');
3152
+ 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');
3153
+ const codexAppOldCliStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodex, version: 'codex-cli 0.129.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3154
+ 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');
3090
3155
  const missingDefaultPluginTmp = tmpdir();
3091
3156
  await ensureDir(path.join(missingDefaultPluginTmp, '.codex'));
3092
3157
  const codexConfigWithoutMarketplaceSources = codexConfigText.replace(/(?:^|\n)\[marketplaces\.[^\]\r\n]+\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/g, '').trim();
@@ -3107,7 +3172,12 @@ async function selftest() {
3107
3172
  await writeTextAtomic(fakeCodexMissing, '#!/bin/sh\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then printf "%s\\n" "computer-use enabled" "browser-use enabled"; exit 0; fi\nif [ "$1" = "features" ] && [ "$2" = "list" ]; then cat <<EOF\napps stable true\nbrowser_use stable true\nbrowser_use_external stable true\ncodex_git_commit under development false\ncomputer_use stable true\nfast_mode stable true\nguardian_approval stable true\nhooks stable true\nimage_generation stable true\nin_app_browser stable true\nplugins stable true\nremote_control under development true\ntool_suggest stable true\nEOF\nexit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
3108
3173
  await fsp.chmod(fakeCodexMissing, 0o755);
3109
3174
  const codexAppMissingFeatureStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodexMissing, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3110
- if (codexAppMissingFeatureStatus.ok || codexAppMissingFeatureStatus.features?.required_flags_ok || codexAppMissingFeatureStatus.features?.codex_git_commit) throw new Error('selftest: codex-app check did not block disabled codex_git_commit feature flag');
3175
+ if (codexAppMissingFeatureStatus.ok || codexAppMissingFeatureStatus.features?.required_flags_ok || codexAppMissingFeatureStatus.features?.codex_git_commit || codexAppMissingFeatureStatus.features?.git_actions?.ok) throw new Error('selftest: codex-app check did not block disabled codex_git_commit feature flag');
3176
+ const fakeCodexMissingImageGen = path.join(fakeCodexBinDir, 'codex-missing-imagegen');
3177
+ await writeTextAtomic(fakeCodexMissingImageGen, '#!/bin/sh\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then printf "%s\\n" "computer-use enabled" "browser-use enabled"; exit 0; fi\nif [ "$1" = "features" ] && [ "$2" = "list" ]; then cat <<EOF\napps stable true\nbrowser_use stable true\nbrowser_use_external stable true\ncodex_git_commit under development true\ncomputer_use stable true\nfast_mode stable true\nguardian_approval stable true\nhooks stable true\nimage_generation stable false\nin_app_browser stable true\nplugins stable true\nremote_control under development true\ntool_suggest stable true\nEOF\nexit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
3178
+ await fsp.chmod(fakeCodexMissingImageGen, 0o755);
3179
+ const codexAppMissingImageGenStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodexMissingImageGen, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3180
+ if (codexAppMissingImageGenStatus.ok || codexAppMissingImageGenStatus.features?.required_flags_ok || codexAppMissingImageGenStatus.features?.image_generation || !codexAppMissingImageGenStatus.guidance.some((line) => line.includes('image_generation'))) throw new Error('selftest: codex-app check did not block disabled image_generation for imagegen pipelines');
3111
3181
  const autoReviewHome = path.join(tmp, 'auto-review-home');
3112
3182
  const autoReviewEnv = { HOME: autoReviewHome };
3113
3183
  const autoReviewEnabled = await enableAutoReview({ env: autoReviewEnv, high: true });
@@ -135,7 +135,8 @@ export async function codexAppIntegrationStatus(opts = {}) {
135
135
  const browserToolReady = inAppBrowserReady || browserUseFeatureReady || browserUseReady;
136
136
  const appInstalled = Boolean(appPath);
137
137
  const pluginPickerReady = requiredFeatureFlags.tool_suggest && requiredFeatureFlags.plugins && requiredFeatureFlags.apps && defaultPlugins.ok && pluginSkillShadows.ok && fastModeConfig.ok;
138
- const ready = appInstalled && Boolean(codex.bin) && mcpList.ok && featureList.ok && requiredFeatureFlagsOk && pluginPickerReady && fastModeConfig.ok && imageGenerationReady && computerUseReady && browserToolReady;
138
+ const gitActions = codexGitActionReadiness({ requiredFeatureFlags, remoteControl });
139
+ const ready = appInstalled && Boolean(codex.bin) && mcpList.ok && featureList.ok && requiredFeatureFlagsOk && pluginPickerReady && fastModeConfig.ok && imageGenerationReady && gitActions.ok && computerUseReady && browserToolReady;
139
140
  return {
140
141
  ok: ready,
141
142
  app: {
@@ -166,6 +167,7 @@ export async function codexAppIntegrationStatus(opts = {}) {
166
167
  required_flags: requiredFeatureFlags,
167
168
  required_flags_ok: requiredFeatureFlagsOk,
168
169
  fast_mode_config: fastModeConfig,
170
+ git_actions: gitActions,
169
171
  image_generation: imageGenerationReady,
170
172
  image_generation_source: imageGenerationReady ? 'codex_features_list' : 'missing',
171
173
  in_app_browser: inAppBrowserReady,
@@ -196,7 +198,7 @@ export async function codexAppIntegrationStatus(opts = {}) {
196
198
  fast_mode_config_ok: fastModeConfig.ok
197
199
  }
198
200
  },
199
- guidance: codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags, requiredFeatureFlagsOk, defaultPlugins, pluginSkillShadows, fastModeConfig, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl })
201
+ guidance: codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags, requiredFeatureFlagsOk, defaultPlugins, pluginSkillShadows, fastModeConfig, gitActions, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl })
200
202
  };
201
203
  }
202
204
 
@@ -251,7 +253,7 @@ export function formatCodexRemoteControlStatus(status) {
251
253
  return lines.filter(Boolean).join('\n');
252
254
  }
253
255
 
254
- export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags = {}, requiredFeatureFlagsOk = true, defaultPlugins = { ok: true, missing_enabled: [] }, pluginSkillShadows = { ok: true, blocking: [] }, fastModeConfig = { ok: true, blockers: [] }, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl }) {
256
+ export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags = {}, requiredFeatureFlagsOk = true, defaultPlugins = { ok: true, missing_enabled: [] }, pluginSkillShadows = { ok: true, blocking: [] }, fastModeConfig = { ok: true, blockers: [] }, gitActions = { ok: true, blockers: [] }, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl }) {
255
257
  const lines = [];
256
258
  if (!appInstalled) {
257
259
  lines.push('Install and open Codex App for first-party MCP/plugin tools. SKS tmux launch can still run with Codex CLI alone, but Codex Computer Use and imagegen/gpt-image-2 evidence will be unavailable until Codex App is ready.');
@@ -298,6 +300,12 @@ export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, re
298
300
  lines.push(`Codex App speed selector can be hidden or locked by config: ${fastModeConfig.blockers.join(', ')}.`);
299
301
  lines.push('Run: sks doctor --fix');
300
302
  }
303
+ if (!gitActions?.ok) {
304
+ lines.push(`Codex App git commit/push actions are blocked: ${gitActions?.blockers?.join(', ') || 'git action readiness'}. The app Commit, Push, Commit and Push, and PR flows need codex_git_commit, hooks, remote_control, and Codex CLI remote-control support.`);
305
+ lines.push(`Run: sks doctor --fix; if remote-control is still blocked, update Codex CLI to ${CODEX_REMOTE_CONTROL_MIN_VERSION}+ and restart older app-server/TUI sessions.`);
306
+ } else {
307
+ lines.push('Codex App git actions are enabled for Commit, Push, Commit and Push, and PR flows; SKS hooks treat those app metadata actions as lightweight git UI actions.');
308
+ }
301
309
  if (appInstalled && (!computerUseReady || !browserToolReady)) {
302
310
  lines.push('Open Codex App settings and enable recommended MCP/plugin tools. Codex CLI 0.130.0+ remote-control/app-server sessions can pick up config changes live; restart older CLI/TUI sessions.');
303
311
  lines.push('Required for SKS QA-LOOP UI/browser evidence: Codex Computer Use only. Browser tools can support browsing context, but they do not satisfy UI-level E2E verification.');
@@ -333,6 +341,7 @@ export function formatCodexAppStatus(status, { includeRaw = false } = {}) {
333
341
  `Fast UI: ${status.features?.fast_mode_config?.ok ? 'ok' : `locked ${(status.features?.fast_mode_config?.blockers || []).join(', ') || 'config'}`}`,
334
342
  `Default Plugins:${status.plugins?.default_plugins?.ok ? ' ok' : ` missing ${defaultPluginMissingSummary(status.plugins?.default_plugins) || 'plugin install/config'}`}`,
335
343
  `Plugin Picker:${status.plugins?.picker?.ok ? ' ok' : ` blocked ${pluginPickerBlockers(status).join(', ') || 'config'}`}`,
344
+ `Git Actions:${status.features?.git_actions?.ok ? ' ok' : ` blocked ${(status.features?.git_actions?.blockers || []).join(', ') || 'config'}`}`,
336
345
  `Computer Use:${status.mcp.has_computer_use ? status.mcp.computer_use_source === 'plugin_cache' ? ' installed (verify @Computer in thread)' : ' ok' : ' missing'}`,
337
346
  `Browser: ${status.features?.browser_tool_ready ? `ok (${status.features.browser_tool_source})` : status.mcp.has_browser_use ? status.mcp.browser_use_source === 'plugin_cache' ? 'installed (plugin scoped)' : 'ok' : 'missing'}`,
338
347
  `Image Gen: ${status.features?.image_generation ? 'ok ($imagegen/gpt-image-2)' : status.features?.checked ? 'missing' : 'not checked'}`,
@@ -369,6 +378,25 @@ function missingRequiredFeatureFlags(flags = {}) {
369
378
  return REQUIRED_CODEX_APP_FEATURE_FLAGS.filter((name) => flags?.[name] !== true);
370
379
  }
371
380
 
381
+ function codexGitActionReadiness({ requiredFeatureFlags = {}, remoteControl = {} } = {}) {
382
+ const blockers = [];
383
+ if (requiredFeatureFlags.codex_git_commit !== true) blockers.push('codex_git_commit');
384
+ if (requiredFeatureFlags.hooks !== true) blockers.push('hooks');
385
+ if (requiredFeatureFlags.remote_control !== true) blockers.push('remote_control_feature');
386
+ if (!remoteControl?.ok) blockers.push(remoteControl?.reason || 'codex_cli_remote_control');
387
+ const ok = blockers.length === 0;
388
+ return {
389
+ ok,
390
+ blockers,
391
+ commit: ok,
392
+ push: ok,
393
+ commit_push: ok,
394
+ pull_request: ok,
395
+ required_flags: ['codex_git_commit', 'hooks', 'remote_control'],
396
+ remote_control_min_version: CODEX_REMOTE_CONTROL_MIN_VERSION
397
+ };
398
+ }
399
+
372
400
  async function codexDefaultPluginStatus(opts = {}) {
373
401
  const home = opts.home || os.homedir();
374
402
  const cwd = opts.cwd || process.cwd();
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.1';
8
+ export const PACKAGE_VERSION = '0.9.4';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -59,6 +59,83 @@ function extractCommand(payload) {
59
59
  return payload.command || payload.tool_input?.command || payload.toolInput?.command || payload.input?.command || payload.tool?.input?.command || '';
60
60
  }
61
61
 
62
+ function codexGitActionMetadataText(payload = {}) {
63
+ const seen = new Set();
64
+ const out = [];
65
+ const interesting = new Set([
66
+ 'action',
67
+ 'intent',
68
+ 'operation',
69
+ 'permission',
70
+ 'description',
71
+ 'kind',
72
+ 'type',
73
+ 'feature',
74
+ 'tool_name',
75
+ 'toolName',
76
+ 'name',
77
+ 'label',
78
+ 'title',
79
+ 'source',
80
+ 'event',
81
+ 'hook',
82
+ 'hook_name',
83
+ 'hookName',
84
+ 'hook_event_name',
85
+ 'hookEventName',
86
+ 'id',
87
+ 'command'
88
+ ]);
89
+ const noisy = new Set([
90
+ 'prompt',
91
+ 'user_prompt',
92
+ 'userPrompt',
93
+ 'message',
94
+ 'assistant_message',
95
+ 'last_assistant_message',
96
+ 'response',
97
+ 'raw',
98
+ 'stdout',
99
+ 'stderr'
100
+ ]);
101
+ function walk(value, depth = 0, parentKey = '') {
102
+ if (!value || typeof value !== 'object' || depth > 5 || seen.has(value)) return;
103
+ seen.add(value);
104
+ for (const [key, candidate] of Object.entries(value)) {
105
+ if (noisy.has(key)) continue;
106
+ if (typeof candidate === 'string') {
107
+ if (interesting.has(key) || /\b(?:codex[_\s-]*app|git[_\s-]*actions?|codex_git_|gitCommit|gitPush|pull\s+request)\b/i.test(candidate)) {
108
+ out.push(`${key}:${candidate}`);
109
+ }
110
+ continue;
111
+ }
112
+ if (candidate && typeof candidate === 'object') {
113
+ const allowedContainer = interesting.has(key)
114
+ || /^(?:input|metadata|context|client|thread|session|request|payload|tool|tool_input|toolInput|permission_request|permissionRequest)$/i.test(key)
115
+ || parentKey;
116
+ if (allowedContainer) walk(candidate, depth + 1, key);
117
+ }
118
+ }
119
+ }
120
+ walk(payload);
121
+ return out.join(' ');
122
+ }
123
+
124
+ function codexGitActionMetadataSignal(text = '') {
125
+ const s = String(text || '');
126
+ if (!s) return false;
127
+ const action = String(s)
128
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
129
+ .replace(/[_-]+/g, ' ');
130
+ if (/\bcodex\s*app\b[\s\S]{0,120}\bgit\b[\s\S]{0,120}\b(?:action|actions|commit|push|pr|pull request)\b/i.test(action)) return true;
131
+ if (/\bgit\s*actions?\b[\s\S]{0,120}\b(?:commit|push|pr|pull request|commit\s*(?:and|&)\s*push)\b/i.test(action)) return true;
132
+ if (/\bcodex\s*git\s*(?:commit|push|pr|pull request|commit\s*(?:and|&)\s*push)\b/i.test(action)) return true;
133
+ if (/\b(?:git\s*)?(?:commit|push|commit\s*(?:and|&)\s*push|create\s+(?:a\s+)?pull\s+request|pull\s+request|pr)\b/i.test(action)) {
134
+ return /\b(?:action|intent|operation|permission|feature|tool\s*name|source|event|hook|name|label|title|type|kind|id)\s*:/i.test(action);
135
+ }
136
+ return false;
137
+ }
138
+
62
139
  function toolFailed(payload = {}) {
63
140
  const candidates = [
64
141
  payload.exit_code,
@@ -335,6 +412,7 @@ function looksLikeUserGitAction(payload = {}) {
335
412
  const command = extractCommand(payload);
336
413
  const haystack = [
337
414
  command,
415
+ codexGitActionMetadataText(payload),
338
416
  payload.action,
339
417
  payload.intent,
340
418
  payload.operation,
@@ -345,6 +423,7 @@ function looksLikeUserGitAction(payload = {}) {
345
423
  payload.toolName
346
424
  ].filter(Boolean).join(' ');
347
425
  if (/\b(?:reset\s+--hard|clean\s+-[^\s]*f|checkout\s+--|restore\s+|rm\s+|push\s+--force|push\s+-[^\s]*f)\b/i.test(command)) return false;
426
+ if (codexGitActionMetadataSignal(haystack)) return true;
348
427
  if (/\bcodex\b[\s_-]*(?:app\s*)?(?:git\s*)?(?:action|commit|push|pr)\b/i.test(haystack)) return true;
349
428
  if (!/^\s*git\s+/i.test(command)) return false;
350
429
  return /\bgit\s+(?:status|diff|add|commit|push|branch|remote|rev-parse|log)\b/i.test(command);
@@ -474,7 +553,9 @@ function explicitConversationId(payload = {}) {
474
553
 
475
554
  function looksLikeCodexGitAction(payload = {}) {
476
555
  const prompt = stripVisibleDecisionAnswerBlocks(extractUserPrompt(payload));
556
+ const metadataText = codexGitActionMetadataText(payload);
477
557
  const haystack = [
558
+ metadataText,
478
559
  payload.action,
479
560
  payload.intent,
480
561
  payload.operation,
@@ -502,9 +583,10 @@ function looksLikeCodexGitAction(payload = {}) {
502
583
  ].filter(Boolean).join(' ');
503
584
  const codexAppGitSignal = /\bcodex[_\s-]*app\b[\s\S]{0,80}\bgit\b[\s\S]{0,80}\b(?:action|actions|commit|push|pr)\b/i.test(haystack);
504
585
  const gitActionSignal = /\bgit[_\s-]*actions?\b[\s\S]{0,80}\b(?:commit|push|commit[\s_-]*(?:and|&)?[\s_-]*push)\b/i.test(haystack);
505
- const appSignal = codexAppGitSignal
586
+ const appSignal = codexGitActionMetadataSignal(metadataText)
587
+ || codexAppGitSignal
506
588
  || gitActionSignal
507
- || /\b(?:codex[_\s-]*(?:app[_\s-]*)?)?(?:git[_\s-]*)?(?:commit[_\s-]*message|git[_\s-]*commit|codex_git_commit)\b/i.test(haystack)
589
+ || /\b(?:codex[_\s-]*(?:app[_\s-]*)?)?(?:git[_\s-]*)?(?:commit[_\s-]*message|git[_\s-]*commit|git[_\s-]*push|git[_\s-]*pr|codex_git_commit|codex_git_push|codex_git_pr)\b/i.test(haystack)
508
590
  || /커밋\s*메시지\s*생성/i.test(haystack);
509
591
  const promptSignal = /\bgenerate(?:\s+a)?(?:\s+git)?\s+commit\s+message\b/i.test(prompt)
510
592
  || /\bcommit\s+message\b[\s\S]{0,80}\b(?:staged|diff|changes?|git)\b/i.test(prompt)
@@ -524,7 +606,9 @@ function looksLikeStockCodexGitActionPrompt(prompt = '') {
524
606
 
525
607
  function looksLikeCodexGitActionStopCompletion(last = '', payload = {}) {
526
608
  const text = String(last || '').trim();
609
+ const metadataText = codexGitActionMetadataText(payload);
527
610
  const haystack = [
611
+ metadataText,
528
612
  payload.action,
529
613
  payload.intent,
530
614
  payload.operation,
@@ -539,6 +623,7 @@ function looksLikeCodexGitActionStopCompletion(last = '', payload = {}) {
539
623
  payload.metadata?.feature,
540
624
  payload.metadata?.source
541
625
  ].filter(Boolean).join(' ');
626
+ if (codexGitActionMetadataSignal(metadataText)) return true;
542
627
  if (/\bcodex[_\s-]*app\b[\s\S]{0,80}\bgit\b[\s\S]{0,80}\b(?:action|commit|push|pr)\b/i.test(haystack)) return true;
543
628
  if (!text || text.length > 180) return false;
544
629
  return /^(?:commit(?:ted)?(?:\s+and\s+pushed)?(?:\s+changes)?(?:\s+complete[.!]?)?|push(?:ed)?(?:\s+changes)?(?:\s+complete[.!]?)?|created\s+(?:a\s+)?pull\s+request[.!]?)$/i.test(text);
@@ -1010,6 +1095,14 @@ export async function selftestCodexCommitHooks() {
1010
1095
  const appCommitPushStop = await runHook('stop', { conversation_id: commitPushId, last_assistant_message: 'Commit and push complete.' });
1011
1096
  if (appCommitPushStop.code !== 0) throw new Error(`selftest failed: app commit-push stop ${appCommitPushStop.code}: ${appCommitPushStop.stderr}`);
1012
1097
  if (JSON.parse(appCommitPushStop.stdout).decision === 'block') throw new Error('selftest failed: app commit-push stop bypass');
1098
+ const appPushId = 'app-push-selftest';
1099
+ const appPushHook = await runHook('user-prompt-submit', { conversation_id: appPushId, metadata: { source: 'codex_app', action: 'Git Actions Push' }, prompt: 'Push changes.' });
1100
+ if (appPushHook.code !== 0) throw new Error(`selftest failed: app push hook ${appPushHook.code}: ${appPushHook.stderr}`);
1101
+ const appPushJson = JSON.parse(appPushHook.stdout);
1102
+ if (appPushJson.decision === 'block' || appPushJson.hookSpecificOutput?.additionalContext || !String(appPushJson.systemMessage || '').includes('git action')) throw new Error('selftest failed: app push metadata route bypass');
1103
+ const appPushStop = await runHook('stop', { conversation_id: appPushId, metadata: { source: 'codex_app', action: 'Git Actions Push' }, last_assistant_message: 'Done.' });
1104
+ if (appPushStop.code !== 0) throw new Error(`selftest failed: app push stop ${appPushStop.code}: ${appPushStop.stderr}`);
1105
+ if (JSON.parse(appPushStop.stdout).decision === 'block') throw new Error('selftest failed: app push metadata stop bypass');
1013
1106
  const metadataLightId = 'metadata-light-commit-push-selftest';
1014
1107
  const metadataLightHook = await runHook('user-prompt-submit', { conversation_id: metadataLightId, prompt: 'Commit and push changes.' });
1015
1108
  if (metadataLightHook.code !== 0) throw new Error(`selftest failed: metadata-light commit-push hook ${metadataLightHook.code}: ${metadataLightHook.stderr}`);