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 +40 -1
- package/package.json +1 -1
- package/src/cli/install-helpers.mjs +394 -11
- package/src/cli/main.mjs +78 -8
- package/src/core/codex-app.mjs +31 -3
- package/src/core/fsx.mjs +1 -1
- package/src/core/hooks-runtime.mjs +95 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/core/codex-app.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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 =
|
|
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}`);
|